Repository: NoeFabris/opencode-antigravity-auth Branch: main Commit: 09ccf4bbfe1a Files: 127 Total size: 1.3 MB Directory structure: gitextract_q9qv3hab/ ├── .github/ │ ├── FUNDING.yml │ ├── ISSUE_TEMPLATE/ │ │ ├── bug_report.yml │ │ ├── config.yml │ │ └── feature_request.yml │ └── workflows/ │ ├── ci.yml │ ├── issue-triage.yml │ ├── release-beta.yml │ ├── release.yml │ ├── republish-version.yml │ └── update-dist-tag.yml ├── .gitignore ├── AGENTS.MD ├── CHANGELOG.md ├── LICENSE ├── README.md ├── assets/ │ └── antigravity.schema.json ├── docs/ │ ├── ANTIGRAVITY_API_SPEC.md │ ├── ARCHITECTURE.md │ ├── CONFIGURATION.md │ ├── MODEL-VARIANTS.md │ ├── MULTI-ACCOUNT.md │ └── TROUBLESHOOTING.md ├── index.ts ├── package.json ├── script/ │ ├── build-schema.ts │ ├── test-cross-model-e2e.sh │ ├── test-cross-model.ts │ ├── test-gemini-cli-e2e.sh │ ├── test-models.ts │ └── test-regression.ts ├── scripts/ │ ├── README-PI.md │ ├── auth-pi-tools.sh │ ├── check-quota.mjs │ ├── setup-opencode-pi.sh │ └── setup-pi-runner.sh ├── src/ │ ├── antigravity/ │ │ └── oauth.ts │ ├── constants.test.ts │ ├── constants.ts │ ├── hooks/ │ │ └── auto-update-checker/ │ │ ├── cache.ts │ │ ├── checker.ts │ │ ├── constants.ts │ │ ├── index.test.ts │ │ ├── index.ts │ │ ├── logging.ts │ │ └── types.ts │ ├── plugin/ │ │ ├── accounts.test.ts │ │ ├── accounts.ts │ │ ├── antigravity-first-fallback.test.ts │ │ ├── auth.test.ts │ │ ├── auth.ts │ │ ├── cache/ │ │ │ ├── index.ts │ │ │ └── signature-cache.ts │ │ ├── cache.test.ts │ │ ├── cache.ts │ │ ├── cli.ts │ │ ├── config/ │ │ │ ├── index.ts │ │ │ ├── loader.ts │ │ │ ├── models.test.ts │ │ │ ├── models.ts │ │ │ ├── schema.test.ts │ │ │ ├── schema.ts │ │ │ ├── updater.test.ts │ │ │ └── updater.ts │ │ ├── core/ │ │ │ └── streaming/ │ │ │ ├── index.ts │ │ │ ├── transformer.ts │ │ │ └── types.ts │ │ ├── cross-model-integration.test.ts │ │ ├── debug.test.ts │ │ ├── debug.ts │ │ ├── errors.ts │ │ ├── fingerprint.ts │ │ ├── image-saver.ts │ │ ├── logger.test.ts │ │ ├── logger.ts │ │ ├── logging-utils.test.ts │ │ ├── logging-utils.ts │ │ ├── model-specific-quota.test.ts │ │ ├── persist-account-pool.test.ts │ │ ├── project.ts │ │ ├── quota-fallback.test.ts │ │ ├── quota.ts │ │ ├── recovery/ │ │ │ ├── constants.ts │ │ │ ├── index.ts │ │ │ ├── storage.ts │ │ │ └── types.ts │ │ ├── recovery.test.ts │ │ ├── recovery.ts │ │ ├── refresh-queue.test.ts │ │ ├── refresh-queue.ts │ │ ├── request-helpers.test.ts │ │ ├── request-helpers.ts │ │ ├── request.test.ts │ │ ├── request.ts │ │ ├── rotation.test.ts │ │ ├── rotation.ts │ │ ├── search.ts │ │ ├── server.ts │ │ ├── storage.test.ts │ │ ├── storage.ts │ │ ├── stores/ │ │ │ └── signature-store.ts │ │ ├── thinking-recovery.ts │ │ ├── token.test.ts │ │ ├── token.ts │ │ ├── transform/ │ │ │ ├── claude.test.ts │ │ │ ├── claude.ts │ │ │ ├── cross-model-sanitizer.test.ts │ │ │ ├── cross-model-sanitizer.ts │ │ │ ├── gemini.test.ts │ │ │ ├── gemini.ts │ │ │ ├── index.ts │ │ │ ├── model-resolver.test.ts │ │ │ ├── model-resolver.ts │ │ │ └── types.ts │ │ ├── types.ts │ │ ├── ui/ │ │ │ ├── ansi.test.ts │ │ │ ├── ansi.ts │ │ │ ├── auth-menu.test.ts │ │ │ ├── auth-menu.ts │ │ │ ├── confirm.ts │ │ │ └── select.ts │ │ ├── version.test.ts │ │ └── version.ts │ ├── plugin.ts │ └── shims.d.ts ├── tsconfig.build.json ├── tsconfig.json └── vitest.config.ts ================================================ FILE CONTENTS ================================================ ================================================ FILE: .github/FUNDING.yml ================================================ # These are supported funding model platforms github: NoeFabris patreon: # Replace with a single Patreon username open_collective: # Replace with a single Open Collective username ko_fi: noefabris tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry liberapay: # Replace with a single Liberapay username issuehunt: # Replace with a single IssueHunt username lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry polar: # Replace with a single Polar username buy_me_a_coffee: # Replace with a single Buy Me a Coffee username thanks_dev: # Replace with a single thanks.dev username custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] ================================================ FILE: .github/ISSUE_TEMPLATE/bug_report.yml ================================================ name: Bug Report description: Report a bug or issue with the plugin labels: ["bug", "needs-triage"] body: - type: markdown attributes: value: | ## Before opening an issue > ⚠️ **PLEASE READ THE TROUBLESHOOTING GUIDE FIRST** > > Most issues (rate limits, hanging prompts, auth errors) are covered in our troubleshooting guide. > **Your issue may already have a solution!** **Please write a descriptive title above** (e.g., "Auth fails with invalid_grant after token refresh") > 🌐 **ENGLISH ONLY** > > Issues must be written in English. Issues in other languages will be closed immediately. Check the following resources first: - **[📋 Troubleshooting Guide](https://github.com/NoeFabris/opencode-antigravity-auth/blob/main/docs/TROUBLESHOOTING.md)** - common issues and solutions - [Existing issues](https://github.com/NoeFabris/opencode-antigravity-auth/issues?q=is%3Aissue) - your issue may already be reported **Issues without debug logs will be closed.** - type: checkboxes id: prerequisites attributes: label: Pre-submission checklist options: - label: I have searched existing issues for duplicates required: true - label: I have read the [📋 Troubleshooting Guide](https://github.com/NoeFabris/opencode-antigravity-auth/blob/main/docs/TROUBLESHOOTING.md) required: true - label: I have read the [README](https://github.com/NoeFabris/opencode-antigravity-auth#readme) installation instructions - type: dropdown id: model attributes: label: Model used description: Which model were you using when the issue occurred? options: - antigravity-gemini-3-pro - antigravity-gemini-3-flash - antigravity-claude-sonnet-4-6 - antigravity-claude-opus-4-6-thinking - gemini-2.5-flash - gemini-2.5-pro - gemini-3-flash-preview - gemini-3-pro-preview - Other (specify in description) - Not applicable validations: required: true - type: textarea id: error-message attributes: label: Exact error message description: Copy-paste the exact error message. Do not paraphrase. placeholder: | Paste the exact error message here. Include any error codes or stack traces if available. render: text validations: required: true - type: textarea id: description attributes: label: Bug description description: A clear and concise description of what the bug is. placeholder: Describe the bug... validations: required: true - type: textarea id: steps attributes: label: Steps to reproduce description: How can we reproduce this issue? placeholder: | 1. Run command... 2. Click on... 3. See error... validations: required: true - type: dropdown id: worked-before attributes: label: Did this ever work? description: Has this feature worked for you before? options: - First time setup (never worked) - Worked before, now broken (regression) - Not sure validations: required: true - type: dropdown id: num-accounts attributes: label: Number of Google accounts configured options: - "1" - "2-3" - "4+" validations: required: true - type: dropdown id: reproducibility attributes: label: Reproducibility description: How often does this issue occur? options: - Always (100%) - Often (>50%) - Sometimes (<50%) - Once (cannot reproduce) validations: required: true - type: input id: plugin-version attributes: label: Plugin version description: "Run: `npm list opencode-antigravity-auth -g 2>/dev/null || npm list opencode-antigravity-auth`" placeholder: "e.g., 1.2.9-beta.5" validations: required: true - type: input id: opencode-version attributes: label: OpenCode version description: "Run: `opencode --version`" placeholder: "e.g., 0.3.0" validations: required: true - type: input id: os attributes: label: Operating System placeholder: "e.g., macOS 15.2, Ubuntu 24.04, Windows 11" validations: required: true - type: input id: node-version attributes: label: Node.js version description: "Run: `node --version`" placeholder: "e.g., v22.12.0" validations: required: true - type: dropdown id: environment attributes: label: Environment type description: Are you running in a special environment? options: - Standard (native terminal) - WSL2 - Docker - SSH / Remote - VS Code Remote - Other (specify in additional context) validations: required: true - type: textarea id: mcp-servers attributes: label: MCP servers installed description: List any MCP (Model Context Protocol) servers you have configured. This helps us identify potential interactions or conflicts. placeholder: | Example: - chrome-devtools - figma validations: required: false - type: textarea id: logs attributes: label: Debug logs (REQUIRED) description: | **Issues without logs will be closed.** 1. Enable debug logging: `export OPENCODE_ANTIGRAVITY_DEBUG=2` 2. Reproduce the issue 3. Find log file at: - macOS/Linux: `~/.config/opencode/antigravity-logs/antigravity-debug-.log` - Windows: `%APPDATA%\opencode\antigravity-logs\antigravity-debug-.log` 4. Paste relevant sections or attach the file placeholder: Paste your debug logs here... render: text validations: required: true - type: textarea id: config attributes: label: Configuration (optional) description: Share your antigravity.json if relevant (remove sensitive data) placeholder: | { "quiet_mode": false, "debug": false } render: json validations: required: false - type: checkboxes id: compliance attributes: label: Compliance options: - label: I'm using this plugin for personal development only required: true - label: This issue is not related to commercial use or TOS violations required: true - type: textarea id: additional attributes: label: Additional context description: Any other relevant information validations: required: false ================================================ FILE: .github/ISSUE_TEMPLATE/config.yml ================================================ blank_issues_enabled: false contact_links: - name: Troubleshooting Guide url: https://github.com/NoeFabris/opencode-antigravity-auth#troubleshoot about: Check the troubleshooting section first - most issues are covered here - name: Search Existing Issues url: https://github.com/NoeFabris/opencode-antigravity-auth/issues?q=is%3Aissue about: Your issue may have already been reported or resolved - name: Discussions url: https://github.com/NoeFabris/opencode-antigravity-auth/discussions about: Ask questions or discuss the plugin with the community - name: Google Cloud Support url: https://cloud.google.com/support about: For Google Cloud account or billing issues, contact Google Cloud directly ================================================ FILE: .github/ISSUE_TEMPLATE/feature_request.yml ================================================ name: Feature Request description: Suggest a new feature or enhancement labels: ["enhancement", "needs-triage"] body: - type: markdown attributes: value: | ## Before opening a feature request **Please write a descriptive title above** (e.g., "Add support for custom retry strategies") > 🌐 **ENGLISH ONLY** > > Issues must be written in English. Issues in other languages will be closed immediately. Check if this feature has already been requested: - [Existing feature requests](https://github.com/NoeFabris/opencode-antigravity-auth/issues?q=is%3Aissue+label%3Aenhancement) - [Discussions](https://github.com/NoeFabris/opencode-antigravity-auth/discussions) - type: checkboxes id: prerequisites attributes: label: Pre-submission checklist options: - label: I have searched existing issues and feature requests for duplicates required: true - label: I have read the [README](https://github.com/NoeFabris/opencode-antigravity-auth#readme) required: true - type: textarea id: description attributes: label: Feature description description: A clear description of the feature you'd like to see. placeholder: Describe the feature... validations: required: true - type: textarea id: use-case attributes: label: Use case description: Explain how this feature would be used and what problem it solves. placeholder: | As a user, I want to... So that I can... validations: required: true - type: textarea id: implementation attributes: label: Proposed implementation description: If you have ideas about how this could be implemented, share them here. placeholder: Optional implementation details... validations: required: false - type: textarea id: alternatives attributes: label: Alternatives considered description: Have you considered any alternative solutions or workarounds? placeholder: Other approaches you've thought about... validations: required: false - type: checkboxes id: compliance attributes: label: Compliance options: - label: This feature is for personal development use required: true - label: This feature does not violate or circumvent Google's Terms of Service required: true - type: textarea id: additional attributes: label: Additional context description: Add any other context, screenshots, or examples. validations: required: false ================================================ FILE: .github/workflows/ci.yml ================================================ name: CI on: push: branches: [main] pull_request: branches: [main] jobs: test: name: Test on Node.js runs-on: ubuntu-latest steps: - name: Checkout code uses: actions/checkout@v4 - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: 20 cache: 'npm' - name: Install dependencies run: npm ci - name: Run type check run: npm run typecheck - name: Run tests run: npm test - name: Build run: npm run build ================================================ FILE: .github/workflows/issue-triage.yml ================================================ name: '🏷️ Issue Triage' on: issues: types: - 'opened' - 'reopened' issue_comment: types: - 'created' workflow_dispatch: inputs: issue_number: description: 'Issue number to triage' required: true type: 'number' concurrency: group: '${{ github.workflow }}-${{ github.event.issue.number || github.event.inputs.issue_number }}' cancel-in-progress: true permissions: contents: 'read' issues: 'write' jobs: triage-issue: if: | github.event_name == 'workflow_dispatch' || github.event_name == 'issues' || ( github.event_name == 'issue_comment' && contains(github.event.comment.body, '/triage') && ( github.event.comment.author_association == 'OWNER' || github.event.comment.author_association == 'MEMBER' || github.event.comment.author_association == 'COLLABORATOR' ) ) timeout-minutes: 30 runs-on: self-hosted steps: - name: 'Check if already triaged' id: 'check_labels' if: github.event_name != 'workflow_dispatch' uses: 'actions/github-script@v7' with: script: | const labels = context.payload.issue?.labels?.map(l => l.name) || []; const triageLabels = ['bug', 'enhancement', 'documentation', 'question', 'invalid']; const areaLabels = labels.filter(l => l.startsWith('area/')); const hasTriageLabel = labels.some(l => triageLabels.includes(l)) || areaLabels.length > 0; const isRetriage = context.payload.comment?.body?.includes('/triage'); if (hasTriageLabel && !labels.includes('needs-triage') && !isRetriage) { core.info(`Issue already triaged: ${labels.join(', ')}. Skipping.`); core.setOutput('skip', 'true'); } else { core.setOutput('skip', 'false'); } - name: 'Get issue data' id: 'get_issue' if: steps.check_labels.outputs.skip != 'true' uses: 'actions/github-script@v7' with: script: | const issueNumber = context.payload.inputs?.issue_number || context.issue?.number; core.setOutput('number', issueNumber); - name: 'Run Opencode Triage' id: 'opencode_triage' if: steps.check_labels.outputs.skip != 'true' run: | echo "Running Opencode Triage on issue ${{ steps.get_issue.outputs.number }}..." # Move to the repository cd /home/admin/coding/opencode-antigravity-auth # Run CLI with the agent config OUTPUT=$(opencode run --agent triage-bot "Triage issue ${{ steps.get_issue.outputs.number }}") echo "Opencode Output: $OUTPUT" echo "result<> $GITHUB_OUTPUT echo "$OUTPUT" >> $GITHUB_OUTPUT echo "EOF" >> $GITHUB_OUTPUT - name: 'Apply Labels and Respond' if: steps.check_labels.outputs.skip != 'true' uses: 'actions/github-script@v7' env: ISSUE_NUMBER: '${{ steps.get_issue.outputs.number }}' OPENCODE_OUTPUT: '${{ steps.opencode_triage.outputs.result }}' with: script: | const rawOutput = process.env.OPENCODE_OUTPUT; if (!rawOutput) { core.warning('No triage output available'); return; } core.info(`Triage output: ${rawOutput}`); let parsed; try { parsed = JSON.parse(rawOutput.trim()); } catch (e) { // Try to extract JSON from code blocks first const codeBlockMatch = rawOutput.match(/```(?:json)?\s*([\s\S]*?)\s*```/); // Find the LAST JSON object containing triage fields (avoid matching code snippets) // Match JSON objects that contain "type_label" to ensure we get the triage output const triageJsonMatch = rawOutput.match(/\{"type_label"[\s\S]*?\}(?=\s*$|\s*```|\s*\n\n)/); // Fallback: find all potential JSON objects and try parsing from the end let jsonStr = codeBlockMatch?.[1] || triageJsonMatch?.[0]; if (!jsonStr) { // Last resort: find all { } pairs and try parsing from the last one const allMatches = [...rawOutput.matchAll(/\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}/g)]; for (let i = allMatches.length - 1; i >= 0; i--) { try { const candidate = JSON.parse(allMatches[i][0]); if (candidate.type_label && candidate.area_label) { jsonStr = allMatches[i][0]; break; } } catch {} } } if (jsonStr) { try { parsed = JSON.parse(jsonStr.trim()); } catch (e2) { core.setFailed(`Failed to parse output: ${rawOutput}`); return; } } else { core.setFailed(`Failed to parse output: ${rawOutput}`); return; } } const typeLabel = parsed.type_label; const areaLabel = parsed.area_label; const duplicateOf = parsed.duplicate_of; const suggestedResponse = parsed.suggested_response; // Validate required fields if (!typeLabel || !areaLabel || suggestedResponse === undefined) { core.setFailed(`Invalid JSON structure: missing required fields. Parsed: ${JSON.stringify(parsed)}`); return; } const validTypeLabels = ['bug', 'enhancement', 'documentation', 'question', 'invalid']; const validAreaLabels = ['area/auth', 'area/models', 'area/config', 'area/compat']; if (!validTypeLabels.includes(typeLabel)) { core.warning(`Invalid type_label: ${typeLabel}`); return; } if (!validAreaLabels.includes(areaLabel)) { core.warning(`Invalid area_label: ${areaLabel}`); return; } let labelsToAdd = []; let labelsToRemove = ['needs-triage']; // Remove existing triage labels to prevent conflicts validTypeLabels.forEach(label => labelsToRemove.push(label)); validAreaLabels.forEach(label => labelsToRemove.push(label)); // Only add one type label and one area label if (typeLabel && validTypeLabels.includes(typeLabel)) { labelsToAdd.push(typeLabel); } if (areaLabel && validAreaLabels.includes(areaLabel)) { labelsToAdd.push(areaLabel); } if (duplicateOf) { labelsToAdd.push('duplicate'); } // Validate single label selection and deduplicate const typeLabels = labelsToAdd.filter(label => validTypeLabels.includes(label)); const areaLabels = labelsToAdd.filter(label => validAreaLabels.includes(label)); const otherLabels = labelsToAdd.filter(label => !validTypeLabels.includes(label) && !validAreaLabels.includes(label)); if (typeLabels.length > 1) { core.warning(`Multiple type labels detected: ${typeLabels.join(', ')}. Using only: ${typeLabel}`); } if (areaLabels.length > 1) { core.warning(`Multiple area labels detected: ${areaLabels.join(', ')}. Using only: ${areaLabel}`); } // Rebuild labelsToAdd with single type and area labels labelsToAdd = []; if (typeLabel && validTypeLabels.includes(typeLabel)) { labelsToAdd.push(typeLabel); } if (areaLabel && validAreaLabels.includes(areaLabel)) { labelsToAdd.push(areaLabel); } labelsToAdd.push(...otherLabels); // Avoid removing labels that are being added labelsToRemove = labelsToRemove.filter(label => !labelsToAdd.includes(label)); const issueNumber = parseInt(process.env.ISSUE_NUMBER); await github.rest.issues.addLabels({ owner: context.repo.owner, repo: context.repo.repo, issue_number: issueNumber, labels: labelsToAdd }); core.info(`Added labels: ${labelsToAdd.join(', ')}`); for (const label of labelsToRemove) { try { await github.rest.issues.removeLabel({ owner: context.repo.owner, repo: context.repo.repo, issue_number: issueNumber, name: label }); core.info(`Removed label: ${label}`); } catch (e) { core.info(`Label ${label} not present, skipping removal`); } } if (suggestedResponse && suggestedResponse.trim()) { let body = `👋 Thanks for opening this issue!\n\n${suggestedResponse}`; if (duplicateOf) { body += `\n\n🔗 This appears to be related to #${duplicateOf}. Please check that issue for updates.`; } body += `\n\n---\n*This is an automated response. A maintainer will review your issue soon.*`; await github.rest.issues.createComment({ owner: context.repo.owner, repo: context.repo.repo, issue_number: issueNumber, body: body }); core.info('Posted response comment'); } if (duplicateOf) { await github.rest.issues.update({ owner: context.repo.owner, repo: context.repo.repo, issue_number: issueNumber, state: 'closed', state_reason: 'not_planned' }); core.info(`Closed as duplicate of #${duplicateOf}`); } - name: 'Comment on failure' if: failure() uses: 'actions/github-script@v7' env: ISSUE_NUMBER: '${{ steps.get_issue.outputs.number || github.event.issue.number }}' RUN_URL: '${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}' with: script: | if (!process.env.ISSUE_NUMBER) return; await github.rest.issues.createComment({ owner: context.repo.owner, repo: context.repo.repo, issue_number: parseInt(process.env.ISSUE_NUMBER), body: `⚠️ Automated triage failed. [View logs](${process.env.RUN_URL})` }); ================================================ FILE: .github/workflows/release-beta.yml ================================================ name: Release Beta on: workflow_dispatch: inputs: force: description: 'Force publish even if version unchanged' required: false default: false type: boolean permissions: contents: write id-token: write jobs: publish-beta: runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v4 with: fetch-depth: 0 token: ${{ secrets.GITHUB_TOKEN }} - name: Use Node.js uses: actions/setup-node@v4 with: node-version: 20 registry-url: https://registry.npmjs.org always-auth: true - name: Verify not on main branch run: | set -euo pipefail CURRENT_BRANCH="${GITHUB_REF#refs/heads/}" if [ "$CURRENT_BRANCH" = "main" ]; then echo "ERROR: Beta release workflow should not run on main branch" >&2 echo "This workflow is for dev branch only" >&2 exit 1 fi echo "Running on branch: $CURRENT_BRANCH" - name: Determine and bump beta version id: determine run: | set -euo pipefail # Get base version from package.json (strip any existing prerelease suffix) RAW_VERSION=$(node -p "require('./package.json').version") BASE_VERSION=$(echo "$RAW_VERSION" | sed 's/-.*$//') echo "base_version=$BASE_VERSION" >> "$GITHUB_OUTPUT" # Fetch all tags git fetch --tags --force # Find the highest beta number for this base version HIGHEST_BETA=-1 for tag in $(git tag -l "v${BASE_VERSION}-beta.*"); do # Extract beta number from tag like v1.2.5-beta.3 BETA_NUM=$(echo "$tag" | sed "s/v${BASE_VERSION}-beta\\.//") if [[ "$BETA_NUM" =~ ^[0-9]+$ ]] && [ "$BETA_NUM" -gt "$HIGHEST_BETA" ]; then HIGHEST_BETA=$BETA_NUM fi done # Also check npm for published beta versions NPM_BETAS=$(npm view opencode-antigravity-auth versions --json 2>/dev/null | grep -oP "\"${BASE_VERSION}-beta\\.\\K[0-9]+" || echo "") for BETA_NUM in $NPM_BETAS; do if [ "$BETA_NUM" -gt "$HIGHEST_BETA" ]; then HIGHEST_BETA=$BETA_NUM fi done # Increment to next beta NEXT_BETA=$((HIGHEST_BETA + 1)) NEXT_VERSION="${BASE_VERSION}-beta.${NEXT_BETA}" echo "next_version=$NEXT_VERSION" >> "$GITHUB_OUTPUT" echo "next_beta=$NEXT_BETA" >> "$GITHUB_OUTPUT" echo "Base version: $BASE_VERSION" echo "Highest existing beta: $HIGHEST_BETA" echo "Next beta version: $NEXT_VERSION" - name: Update package.json version id: update_version run: | NEXT_VERSION="${{ steps.determine.outputs.next_version }}" # Update package.json with new beta version node -e " const fs = require('fs'); const pkg = JSON.parse(fs.readFileSync('package.json', 'utf8')); pkg.version = '$NEXT_VERSION'; fs.writeFileSync('package.json', JSON.stringify(pkg, null, 4) + '\n'); " echo "Updated package.json to version $NEXT_VERSION" # Commit the version bump git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" git add package.json git commit -m "chore: bump version to $NEXT_VERSION [skip ci]" || echo "No changes to commit" git push origin dev || echo "Push failed or no changes" - name: Check if should publish id: should_publish run: | # Always publish since we auto-increment echo "should_publish=true" >> "$GITHUB_OUTPUT" echo "Will publish version ${{ steps.determine.outputs.next_version }}" - name: Verify NPM token if: steps.should_publish.outputs.should_publish == 'true' run: | if [ -z "${NPM_TOKEN}" ]; then echo "NPM_TOKEN secret is required" >&2 exit 1 fi env: NPM_TOKEN: ${{ secrets.NPM_TOKEN }} - name: Install dependencies if: steps.should_publish.outputs.should_publish == 'true' run: npm install - name: Run type check if: steps.should_publish.outputs.should_publish == 'true' run: npm run typecheck - name: Run tests if: steps.should_publish.outputs.should_publish == 'true' run: npm test - name: Build if: steps.should_publish.outputs.should_publish == 'true' run: npm run build - name: Verify build artifacts if: steps.should_publish.outputs.should_publish == 'true' run: | set -euo pipefail [ -f dist/index.js ] || { echo "dist/index.js missing" >&2; exit 1; } [ -f dist/index.d.ts ] || { echo "dist/index.d.ts missing" >&2; exit 1; } [ -d dist/src ] || { echo "dist/src/ missing" >&2; exit 1; } - name: Create git tag if: steps.should_publish.outputs.should_publish == 'true' run: | NEXT_VERSION="${{ steps.determine.outputs.next_version }}" git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" git tag "v$NEXT_VERSION" git push origin "v$NEXT_VERSION" - name: Generate release notes if: steps.should_publish.outputs.should_publish == 'true' id: release_notes run: | set -euo pipefail NEXT_VERSION="${{ steps.determine.outputs.next_version }}" BASE_VERSION="${{ steps.determine.outputs.base_version }}" # Get commits since last tag LAST_TAG=$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || echo "") if [ -n "$LAST_TAG" ]; then CHANGELOG=$(git log --no-merges --pretty=format:'- %s (%h)' "${LAST_TAG}..HEAD" | grep -v "\[skip ci\]" || echo "") COMPARE_URL="https://github.com/${GITHUB_REPOSITORY}/compare/${LAST_TAG}...v${NEXT_VERSION}" else CHANGELOG=$(git log --no-merges --pretty=format:'- %s (%h)' -20 | grep -v "\[skip ci\]" || echo "") COMPARE_URL="" fi if [ -z "$CHANGELOG" ]; then CHANGELOG="- Beta release ${NEXT_VERSION}" fi BODY_FILE=$(mktemp) { echo "## Beta Release v${NEXT_VERSION}" echo "" echo "⚠️ **This is a beta release for testing. Use at your own risk.**" echo "" echo "Base version: \`${BASE_VERSION}\`" echo "" if [ -n "$COMPARE_URL" ]; then echo "Compare changes: $COMPARE_URL" echo "" fi printf "%s\n" "$CHANGELOG" echo "" echo "### Install Beta" echo "" echo "Update your \`opencode.json\`:" echo "" printf '%s\n' '```json' printf '%s\n' '{' printf '%s\n' " \"plugin\": [\"opencode-antigravity-auth@${NEXT_VERSION}\"]" printf '%s\n' '}' printf '%s\n' '```' echo "" echo "Or use the beta tag (always latest beta):" echo "" printf '%s\n' '```json' printf '%s\n' '{' printf '%s\n' ' "plugin": ["opencode-antigravity-auth@beta"]' printf '%s\n' '}' printf '%s\n' '```' echo "" echo "Clear cache if stuck on old version:" echo "" printf '%s\n' '```bash' printf '%s\n' 'rm -rf ~/.cache/opencode/node_modules ~/.cache/opencode/bun.lock' printf '%s\n' '```' } >"$BODY_FILE" { echo "body<>"$GITHUB_OUTPUT" - name: Create GitHub pre-release if: steps.should_publish.outputs.should_publish == 'true' uses: actions/create-release@v1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: tag_name: v${{ steps.determine.outputs.next_version }} release_name: v${{ steps.determine.outputs.next_version }} (Beta) body: ${{ steps.release_notes.outputs.body }} prerelease: true draft: false - name: Publish to npm with beta tag if: steps.should_publish.outputs.should_publish == 'true' env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} run: npm publish --access public --tag beta --provenance - name: Summary if: steps.should_publish.outputs.should_publish == 'true' run: | echo "## Beta Release Published! 🚀" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY echo "**Version:** ${{ steps.determine.outputs.next_version }}" >> $GITHUB_STEP_SUMMARY echo "**Base:** ${{ steps.determine.outputs.base_version }}" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY echo "**Install:**" >> $GITHUB_STEP_SUMMARY echo '```json' >> $GITHUB_STEP_SUMMARY echo '{ "plugin": ["opencode-antigravity-auth@beta"] }' >> $GITHUB_STEP_SUMMARY echo '```' >> $GITHUB_STEP_SUMMARY ================================================ FILE: .github/workflows/release.yml ================================================ name: Release on: push: branches: - main workflow_dispatch: permissions: contents: write id-token: write jobs: publish: runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v4 with: fetch-depth: 0 - name: Use Node.js uses: actions/setup-node@v4 with: node-version: 20 registry-url: https://registry.npmjs.org always-auth: true - name: Determine release state id: determine run: | set -euo pipefail CURRENT_VERSION=$(node -p "require('./package.json').version") echo "current_version=$CURRENT_VERSION" >> "$GITHUB_OUTPUT" # Abort if version contains beta/alpha/rc (prerelease identifiers) if echo "$CURRENT_VERSION" | grep -qE '(beta|alpha|rc)'; then echo "ERROR: Cannot publish prerelease version '$CURRENT_VERSION' as official release" >&2 echo "This is main branch - version should not contain beta/alpha/rc" >&2 exit 1 fi if git rev-parse HEAD^ >/dev/null 2>&1; then PREVIOUS_VERSION=$(node -e "const { execSync } = require('node:child_process'); try { const data = execSync('git show HEAD^:package.json', { stdio: ['ignore', 'pipe', 'ignore'] }); const json = JSON.parse(data.toString()); if (json && typeof json.version === 'string') { process.stdout.write(json.version); } } catch (error) {}") PREVIOUS_VERSION=${PREVIOUS_VERSION//$'\n'/} else PREVIOUS_VERSION="" fi echo "previous_version=$PREVIOUS_VERSION" >> "$GITHUB_OUTPUT" if [ "$CURRENT_VERSION" = "$PREVIOUS_VERSION" ]; then echo "changed=false" >> "$GITHUB_OUTPUT" else echo "changed=true" >> "$GITHUB_OUTPUT" fi git fetch --tags --force if git tag -l "v$CURRENT_VERSION" | grep -q "v$CURRENT_VERSION"; then echo "tag_exists=true" >> "$GITHUB_OUTPUT" else echo "tag_exists=false" >> "$GITHUB_OUTPUT" fi - name: Verify NPM token if: steps.determine.outputs.changed == 'true' && steps.determine.outputs.tag_exists == 'false' run: | if [ -z "${NPM_TOKEN}" ]; then echo "NPM_TOKEN secret is required" >&2 exit 1 fi env: NPM_TOKEN: ${{ secrets.NPM_TOKEN }} - name: Install dependencies if: steps.determine.outputs.changed == 'true' && steps.determine.outputs.tag_exists == 'false' run: npm install - name: Run Tests if: steps.determine.outputs.changed == 'true' && steps.determine.outputs.tag_exists == 'false' run: npm test - name: Update README and Tag if: steps.determine.outputs.changed == 'true' && steps.determine.outputs.tag_exists == 'false' id: update_readme run: | CURRENT_VERSION="${{ steps.determine.outputs.current_version }}" git checkout main git pull origin main # Update version in README.md (looking for pattern opencode-antigravity-auth@x.y.z) sed -i "s/opencode-antigravity-auth@[0-9]\+\.[0-9]\+\.[0-9]\+/opencode-antigravity-auth@$CURRENT_VERSION/g" README.md if ! git diff --quiet README.md; then git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" git add README.md git commit -m "docs: update readme version to $CURRENT_VERSION [skip ci]" git push origin main echo "README updated" else echo "README already up to date" fi # Create and push tag on the current HEAD git tag "v$CURRENT_VERSION" git push origin "v$CURRENT_VERSION" - name: Build if: steps.determine.outputs.changed == 'true' && steps.determine.outputs.tag_exists == 'false' run: npm run build - name: Verify build artifacts if: steps.determine.outputs.changed == 'true' && steps.determine.outputs.tag_exists == 'false' run: | set -euo pipefail [ -f dist/index.js ] || { echo "dist/index.js missing" >&2; exit 1; } [ -f dist/index.d.ts ] || { echo "dist/index.d.ts missing" >&2; exit 1; } [ -d dist/src ] || { echo "dist/src/ missing" >&2; exit 1; } - name: Generate release notes if: steps.determine.outputs.changed == 'true' && steps.determine.outputs.tag_exists == 'false' id: release_notes run: | set -euo pipefail CURRENT_VERSION="${{ steps.determine.outputs.current_version }}" PREVIOUS_VERSION="${{ steps.determine.outputs.previous_version }}" RANGE="" COMPARE_URL="" LAST_TAG=$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || true) if [ -z "$LAST_TAG" ] && [ -n "$PREVIOUS_VERSION" ] && git rev-parse "refs/tags/v${PREVIOUS_VERSION}" >/dev/null 2>&1; then LAST_TAG="v${PREVIOUS_VERSION}" fi if [ -n "$LAST_TAG" ]; then RANGE="${LAST_TAG}..HEAD" COMPARE_URL="https://github.com/${GITHUB_REPOSITORY}/compare/${LAST_TAG}...v${CURRENT_VERSION}" fi if [ -n "$RANGE" ]; then CHANGELOG=$(git log --no-merges --pretty=format:'- %s (%h)' "$RANGE") else CHANGELOG=$(git log --no-merges --pretty=format:'- %s (%h)') fi if [ -z "$CHANGELOG" ]; then CHANGELOG="- No commits found for this release." fi BODY_FILE=$(mktemp) { echo "## Release v${CURRENT_VERSION}" echo "" if [ -n "$COMPARE_URL" ]; then echo "Compare changes: $COMPARE_URL" echo "" fi printf "%s\n" "$CHANGELOG" echo "" echo "### Upgrade" echo "" echo "Update your \`opencode.json\`:" echo "" printf '%s\n' '```json' printf '%s\n' '{' printf '%s\n' " \"plugins\": [\"opencode-antigravity-auth@${CURRENT_VERSION}\"]" printf '%s\n' '}' printf '%s\n' '```' echo "" echo "If stuck on an old version, clear the cache:" echo "" printf '%s\n' '```bash' printf '%s\n' 'rm -rf ~/.cache/opencode/node_modules ~/.cache/opencode/bun.lock' printf '%s\n' '```' } >"$BODY_FILE" cat "$BODY_FILE" { echo "body<>"$GITHUB_OUTPUT" - name: Create GitHub release if: steps.determine.outputs.changed == 'true' && steps.determine.outputs.tag_exists == 'false' uses: actions/create-release@v1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: tag_name: v${{ steps.determine.outputs.current_version }} release_name: v${{ steps.determine.outputs.current_version }} body: ${{ steps.release_notes.outputs.body }} generate_release_notes: false - name: Publish to npm if: steps.determine.outputs.changed == 'true' && steps.determine.outputs.tag_exists == 'false' env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} run: npm publish --access public --provenance ================================================ FILE: .github/workflows/republish-version.yml ================================================ name: Republish Version on: workflow_dispatch: inputs: version: description: 'Version to republish (e.g., 1.2.5)' required: true type: string skip_unpublish: description: 'Skip unpublish step (if version does not exist on npm)' required: false default: false type: boolean permissions: contents: write id-token: write jobs: republish: runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v4 with: fetch-depth: 0 - name: Use Node.js uses: actions/setup-node@v4 with: node-version: 20 registry-url: https://registry.npmjs.org always-auth: true - name: Verify version matches package.json run: | set -euo pipefail REQUESTED_VERSION="${{ github.event.inputs.version }}" PACKAGE_VERSION=$(node -p "require('./package.json').version") if [ "$REQUESTED_VERSION" != "$PACKAGE_VERSION" ]; then echo "ERROR: Requested version ($REQUESTED_VERSION) does not match package.json ($PACKAGE_VERSION)" >&2 echo "Please update package.json to the version you want to publish" >&2 exit 1 fi echo "Version verified: $PACKAGE_VERSION" - name: Unpublish existing version if: github.event.inputs.skip_unpublish != 'true' env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} run: | set -euo pipefail VERSION="${{ github.event.inputs.version }}" echo "Checking if version $VERSION exists on npm..." if npm view opencode-antigravity-auth@$VERSION version >/dev/null 2>&1; then echo "Version $VERSION exists, unpublishing..." npm unpublish opencode-antigravity-auth@$VERSION --force echo "Successfully unpublished version $VERSION" else echo "Version $VERSION does not exist on npm, skipping unpublish" fi - name: Delete existing git tag run: | set -euo pipefail VERSION="${{ github.event.inputs.version }}" TAG="v$VERSION" echo "Checking if tag $TAG exists..." if git tag -l "$TAG" | grep -q "$TAG"; then echo "Tag $TAG exists locally, deleting..." git tag -d "$TAG" || true fi if git ls-remote --tags origin | grep -q "refs/tags/$TAG"; then echo "Tag $TAG exists remotely, deleting..." git push origin :refs/tags/$TAG || true fi echo "Git tag cleanup complete" - name: Verify NPM token run: | if [ -z "${NPM_TOKEN}" ]; then echo "NPM_TOKEN secret is required" >&2 exit 1 fi env: NPM_TOKEN: ${{ secrets.NPM_TOKEN }} - name: Install dependencies run: npm install - name: Run type check run: npm run typecheck - name: Run tests run: npm test - name: Build run: npm run build - name: Verify build artifacts run: | set -euo pipefail [ -f dist/index.js ] || { echo "dist/index.js missing" >&2; exit 1; } [ -f dist/index.d.ts ] || { echo "dist/index.d.ts missing" >&2; exit 1; } [ -d dist/src ] || { echo "dist/src/ missing" >&2; exit 1; } - name: Publish to npm env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} run: | set -euo pipefail VERSION="${{ github.event.inputs.version }}" echo "Publishing version $VERSION to npm..." npm publish --access public --provenance echo "Successfully published version $VERSION" echo "" echo "Updating @latest tag..." npm dist-tag add opencode-antigravity-auth@$VERSION latest echo "" echo "Current dist-tags:" npm dist-tag ls opencode-antigravity-auth - name: Create git tag run: | VERSION="${{ github.event.inputs.version }}" git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" git tag "v$VERSION" git push origin "v$VERSION" - name: Generate release notes id: release_notes run: | set -euo pipefail VERSION="${{ github.event.inputs.version }}" LAST_TAG=$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || echo "") if [ -n "$LAST_TAG" ]; then CHANGELOG=$(git log --no-merges --pretty=format:'- %s (%h)' "${LAST_TAG}..HEAD" | grep -v "\[skip ci\]" || echo "") COMPARE_URL="https://github.com/${GITHUB_REPOSITORY}/compare/${LAST_TAG}...v${VERSION}" else CHANGELOG=$(git log --no-merges --pretty=format:'- %s (%h)' -20 | grep -v "\[skip ci\]" || echo "") COMPARE_URL="" fi if [ -z "$CHANGELOG" ]; then CHANGELOG="- Release v${VERSION}" fi BODY_FILE=$(mktemp) { echo "## Release v${VERSION}" echo "" if [ -n "$COMPARE_URL" ]; then echo "Compare changes: $COMPARE_URL" echo "" fi printf "%s\n" "$CHANGELOG" echo "" echo "### Install" echo "" echo "Update your \`opencode.json\`:" echo "" printf '%s\n' '```json' printf '%s\n' '{' printf '%s\n' " \"plugins\": [\"opencode-antigravity-auth@${VERSION}\"]" printf '%s\n' '}' printf '%s\n' '```' } >"$BODY_FILE" { echo "body<>"$GITHUB_OUTPUT" - name: Create GitHub release uses: actions/create-release@v1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: tag_name: v${{ github.event.inputs.version }} release_name: v${{ github.event.inputs.version }} body: ${{ steps.release_notes.outputs.body }} draft: false prerelease: false - name: Summary run: | echo "## Version Republished! 🚀" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY echo "**Version:** ${{ github.event.inputs.version }}" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY echo "**Install:**" >> $GITHUB_STEP_SUMMARY echo '```bash' >> $GITHUB_STEP_SUMMARY echo "npm install opencode-antigravity-auth@${{ github.event.inputs.version }}" >> $GITHUB_STEP_SUMMARY echo "# or" >> $GITHUB_STEP_SUMMARY echo "npm install opencode-antigravity-auth@latest" >> $GITHUB_STEP_SUMMARY echo '```' >> $GITHUB_STEP_SUMMARY ================================================ FILE: .github/workflows/update-dist-tag.yml ================================================ name: Update NPM Dist Tag on: workflow_dispatch: inputs: version: description: 'Version to tag (e.g., 1.2.5)' required: true type: string tag: description: 'Dist tag to update (e.g., latest, beta)' required: true type: choice options: - latest - beta - next - canary permissions: contents: read id-token: write jobs: update-tag: runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v4 - name: Use Node.js uses: actions/setup-node@v4 with: node-version: 20 registry-url: https://registry.npmjs.org always-auth: true - name: Validate version exists run: | set -euo pipefail VERSION="${{ github.event.inputs.version }}" TAG="${{ github.event.inputs.tag }}" echo "Checking if version $VERSION exists on npm..." if ! npm view opencode-antigravity-auth@$VERSION version >/dev/null 2>&1; then echo "ERROR: Version $VERSION does not exist on npm" >&2 echo "Available versions:" >&2 npm view opencode-antigravity-auth versions --json | tail -20 >&2 exit 1 fi echo "Version $VERSION exists on npm" echo "Current dist-tags:" npm dist-tag ls opencode-antigravity-auth - name: Update dist-tag env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} run: | set -euo pipefail VERSION="${{ github.event.inputs.version }}" TAG="${{ github.event.inputs.tag }}" echo "Updating @$TAG tag to version $VERSION..." npm dist-tag add opencode-antigravity-auth@$VERSION $TAG echo "" echo "Updated dist-tags:" npm dist-tag ls opencode-antigravity-auth - name: Summary run: | echo "## NPM Dist Tag Updated! 🏷️" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY echo "**Version:** ${{ github.event.inputs.version }}" >> $GITHUB_STEP_SUMMARY echo "**Tag:** @${{ github.event.inputs.tag }}" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY echo "Users can now install with:" >> $GITHUB_STEP_SUMMARY echo '```bash' >> $GITHUB_STEP_SUMMARY echo "npm install opencode-antigravity-auth@${{ github.event.inputs.tag }}" >> $GITHUB_STEP_SUMMARY echo '```' >> $GITHUB_STEP_SUMMARY ================================================ FILE: .gitignore ================================================ # dependencies (bun install) node_modules # output out dist *.tgz # code coverage coverage *.lcov # logs logs _.log report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json antigravity-debug-*.log # dotenv environment variable files .env .env.development.local .env.test.local .env.production.local .env.local # caches .eslintcache .cache *.tsbuildinfo # IntelliJ based IDEs .idea # Finder (MacOS) folder config .DS_Store # Hive workflow internal files .hive/ # Test artifacts test-file.ts # Local subrepos (not part of this project) CLIProxyAPI/ LLM-API-Key-Proxy/ opencode/ opencode-better-antigravity-auth/ ================================================ FILE: AGENTS.MD ================================================ # AGENTS.md Guidance for AI agents working with this repository. ## Overview OpenCode plugin for Google Antigravity OAuth. Intercepts `fetch()` calls to `generativelanguage.googleapis.com`, transforms them to Antigravity format, and handles auth, quota, recovery, and multi-account rotation. ## Build & Test Commands ```bash npm install # Install dependencies npm run build # Compile (tsc -p tsconfig.build.json) npm run typecheck # Type-check only (tsc --noEmit) npm test # Run all tests (vitest run) npx vitest run src/plugin/auth.test.ts # Single test file npx vitest run -t "test name here" # Single test by name npx vitest --watch src/plugin/auth.test.ts # Watch mode, single file npm run test:coverage # Coverage report npm run test:e2e:models # E2E: model availability check npm run test:e2e:regression # E2E: regression suite ``` No linter or formatter is configured. Style is enforced by convention (see below). ## TypeScript Configuration - `strict: true` with extra strictness: `noUncheckedIndexedAccess`, `noImplicitOverride`, `noFallthroughCasesInSwitch` - `verbatimModuleSyntax: true` — use `import type` for type-only imports - `target: ESNext`, `module: Preserve`, `moduleResolution: bundler` - `allowImportingTsExtensions: true` — use `.ts` extensions in imports - No path aliases — all imports are relative ## Code Style ### Imports - Use `import type { ... }` for type-only imports (enforced by `verbatimModuleSyntax`) - Named imports only — no default imports in src/ - Relative paths with `.ts` extensions: `import { foo } from "./bar.ts"` - Order: node builtins > external packages > local modules ### Exports - Named exports only in src/ — no default exports - Barrel files (index.ts) for module surfaces ### Naming - `camelCase` for functions, variables, parameters - `PascalCase` for types, interfaces, classes, enums - `UPPER_SNAKE_CASE` for constants - `kebab-case` for file names (e.g., `request-helpers.ts`, `thinking-recovery.ts`) - Test files: `*.test.ts` colocated with source ### Types - No `I` prefix on interfaces, no `Type` suffix - Use `z.infer` for Zod-derived types - Extract to `types.ts` when shared, inline when local - Discriminated unions preferred over boolean flags - Never use `as any`, `@ts-ignore`, or `@ts-expect-error` ### Functions - `export function` for public APIs - Arrow functions for callbacks, factories, and inline closures - Async functions with targeted try/catch (not blanket) ### Error Handling - Defensive try/catch with graceful degradation (fallback values, not crashes) - Custom error classes with metadata when domain-specific - Catch `unknown`, log, and convert to domain errors — never empty catch blocks - Rate limit / quota errors trigger account rotation, not failure ### Formatting - 2-space indentation - Double quotes for strings - Trailing commas in multiline constructs - No semicolons (project convention) ### Logging - `createLogger("module-name")` for structured logging - `console.log` only for CLI/user-facing output ## Module Structure ``` src/ ├── plugin.ts # Main entry, fetch interceptor ├── constants.ts # Endpoints, headers, API config, system prompts ├── antigravity/oauth.ts # OAuth token exchange └── plugin/ ├── auth.ts # Token validation & refresh ├── request.ts # Request transformation (core logic) ├── request-helpers.ts # Schema cleaning, thinking filters ├── thinking-recovery.ts # Turn boundary detection ├── recovery.ts # Session recovery (tool_result_missing) ├── quota.ts # Quota checking (API usage stats) ├── cache.ts # Auth & signature caching ├── accounts.ts # Multi-account management & storage ├── storage.ts # Persistent storage schemas (Zod) ├── fingerprint.ts # Device fingerprint generation & headers ├── project.ts # Managed project context resolution └── debug.ts # Debug logging utilities ``` ## Key Design Patterns ### 1. Request Interception Plugin intercepts `fetch()` for `generativelanguage.googleapis.com`, transforms to Antigravity format. Two header styles: `antigravity` (Electron-style UA + fingerprint) and `gemini-cli` (nodejs-client UA). ### 2. Claude Thinking Blocks ALL thinking blocks are stripped from outgoing requests for Claude models. Claude generates fresh thinking each turn. This eliminates signature validation errors. ### 3. Session Recovery When tool execution is interrupted (ESC/timeout), the plugin injects synthetic `tool_result` blocks to recover the session without starting over. ### 4. Schema Sanitization Tool schemas are cleaned via allowlist. Unsupported fields (`const`, `$ref`, `$defs`) are removed or converted to Antigravity-compatible format. ### 5. Multi-Account Load Balancing Accounts rotate on rate limits. Gemini has dual quota pools (Antigravity headers + Gemini CLI headers). Fingerprints are per-account and regenerated on capacity exhaustion. ### 6. Fingerprint System Per-account device fingerprints stored in `antigravity-accounts.json`. Each fingerprint includes deviceId, sessionToken, userAgent, and a reduced clientMetadata (ideType, platform, pluginType — no osVersion, arch, or sqmId). The only header composed is `User-Agent`, built by `buildFingerprintHeaders()` in `fingerprint.ts` and applied on the antigravity request path in `request.ts`. History tracked (max 5), restorable. ## Dependencies - `zod ^4` — schema validation (NOT zod v3) - `@opencode-ai/plugin` — OpenCode plugin interface - `@openauthjs/openauth` — OAuth client - `proper-lockfile` — file locking for concurrent access - `xdg-basedir` — XDG directory resolution ## Testing - Framework: **Vitest 3** with native ESM - Config: `vitest.config.ts` - Tests colocated: `src/plugin/foo.test.ts` next to `src/plugin/foo.ts` - Use `describe`/`it`/`expect` — standard Vitest API - Mock with `vi.fn()`, `vi.spyOn()`, `vi.mock()` ## Documentation - [README.md](README.md) — Installation & usage - [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) — Detailed architecture guide - [docs/ANTIGRAVITY_API_SPEC.md](docs/ANTIGRAVITY_API_SPEC.md) — API reference - [CHANGELOG.md](CHANGELOG.md) — Version history ================================================ FILE: CHANGELOG.md ================================================ # Changelog ## [1.6.0] - 2026-02-20 ### Fixed - **#397** - Gemini tool-call payload handling now enforces valid `thought_signature` behavior for `functionCall` parts, preventing `400 INVALID_ARGUMENT` in mixed and parallel call turns. - **#454** - Request sanitization now removes empty/invalid `contents.parts` entries and invalid `systemInstruction.parts` before forwarding to Antigravity. - **#444** - Response transform fallback now uses cloned responses and preserves recovery signaling, eliminating `Body already used` failures. - **#368 (Tackled)** - Claude thinking/signature handling now replaces foreign signatures with sentinels and tightens thinking-order classification to reduce false-positive recovery triggers. ### Changed - **Debug Sink Split** - `debug` now controls file logging only, while `debug_tui` independently controls TUI panel logging. - **Header Normalization** - `x-goog-user-project` is now stripped across Antigravity and Gemini CLI request styles. - **Claude Prompt Auto-Caching (Optional)** - Added `claude_prompt_auto_caching` to inject `cache_control: { type: "ephemeral" }` when Claude prompt caching is desired and unset. ### Documentation - Updated README, architecture/config/troubleshooting docs, and generated schema docs to reflect new debug sink semantics and config keys. ## [1.5.2] - 2026-02-18 ### Changed - Added support for Sonnet 4.6 and removed old models support. ## [1.5.1] - 2026-02-11 ### Changed - **Header Identity Alignment** - `ideType` changed from `IDE_UNSPECIFIED` to `ANTIGRAVITY` and `platform` from `PLATFORM_UNSPECIFIED` to dynamic `WINDOWS`/`MACOS` (based on `process.platform`) across all header sources (`getAntigravityHeaders`, `oauth.ts`, `project.ts`). Now matches Antigravity Manager behavior - **Gemini CLI `Client-Metadata` Header** - Gemini CLI requests now include `Client-Metadata` header, aligning with actual `gemini-cli` behavior. Previously only Antigravity-style requests sent this header - **Gemini CLI User-Agent Format** - Updated from `GeminiCLI/{ver}/{model}` to `GeminiCLI/{ver}/{model} ({platform}; {arch})` to match real `gemini-cli` UA strings. Version pool updated from `1.2.0/1.1.0/1.0.0` to `0.28.0/0.27.4/0.27.3` to align with actual release numbers - **Randomized Headers Model-Aware** - `getRandomizedHeaders()` now accepts an optional `model` parameter, embedding the actual model name in Gemini CLI User-Agent strings instead of a hardcoded default - **Fingerprint Platform Alignment** - Antigravity-style `Client-Metadata` platform now consistently matches the randomized User-Agent platform, fixing a potential mismatch where headers could disagree on reported platform ### Removed - **Linux Fingerprints** - Removed `linux/amd64` and `linux/arm64` from `ANTIGRAVITY_PLATFORMS` and fingerprint generation. Linux users now masquerade as macOS (Antigravity does not support Linux as a native platform) - **`getAntigravityUserAgents()` Function** - Removed unused helper that had no callers in the codebase - **`X-Opencode-Tools-Debug` Header** - Removed debug telemetry header from outgoing requests ## [1.5.0] - 2026-02-11 ### Added - **Account Verification Flow** - Auth login menu now supports `verify` and `verify-all` actions. When Antigravity returns a 403 with `validation_required`, the account is automatically disabled, marked with a verification URL, and cooled down. Users can verify accounts directly from the menu with a probe request to confirm resolution - **Dynamic Antigravity Version** - Plugin version is now fetched at startup from the Antigravity updater API, with a changelog-scrape fallback and a hardcoded last-resort. Eliminates stale "version no longer supported" errors after Antigravity updates - **Storage V4 Schema** - New storage version adds `verificationRequired`, `verificationRequiredAt`, `verificationRequiredReason`, `verificationUrl`, and `fingerprintHistory` fields per account. Full migration chain from v1/v2/v3 to v4 - **`saveAccountsReplace`** - New destructive-write storage function that replaces the entire accounts file without merging, preventing deleted accounts from being resurrected by concurrent reads - **`setAccountEnabled` / Account Toggling** - New account management methods: `setAccountEnabled()`, `markAccountVerificationRequired()`, `clearAccountVerificationRequired()`, `removeAccountByIndex()` - **Secure File Permissions** - Credential storage files are now created with mode `0600` (owner read/write only). Existing files with overly permissive modes are tightened on load - **`opencode.jsonc` Support** - Configure models flow now detects and prefers existing `opencode.jsonc` files. JSONC parsing strips comments and trailing commas before JSON.parse - **Header Contract Tests** - New `src/constants.test.ts` validates header shapes, randomization behavior, and optional header fields for both Antigravity and Gemini CLI styles ### Changed - **Unified Gemini Routing** - Gemini quota fallback between Antigravity and Gemini CLI pools is now always enabled for Gemini models. The `quota_fallback` config flag is deprecated and ignored (backward-compatible, no breakage) - **`cli_first` Honored in Routing** - `resolveHeaderRoutingDecision()` centralizes routing logic and properly respects `cli_first` for unsuffixed Gemini models - **Fingerprint Headers Simplified** - `buildFingerprintHeaders()` now returns only `User-Agent`. Removed `X-Goog-QuotaUser`, `X-Client-Device-Id`, `X-Goog-Api-Client`, and `Client-Metadata` from outgoing content requests to align with Antigravity Manager behavior - **Client Metadata Reduced** - Fingerprint client metadata trimmed to `ideType`, `platform`, `pluginType` only. Removed `osVersion`, `arch`, `sqmId` - **Gemini CLI User-Agent Format** - Updated from `google-genai-sdk/...` to `GeminiCLI/...` format - **Search Model** - Changed from `gemini-2.0-flash` to `gemini-2.5-flash` for improved search result quality - **Deterministic Search Generation** - Search requests now use `temperature: 0` and `topP: 1` instead of thinking config - **OAuth Headers Dynamic** - `oauth.ts` and `project.ts` now use `getAntigravityHeaders()` instead of static constants, removing stale `X-Goog-Api-Client` from token/project calls ### Fixed - **#410**: Strip `x-goog-user-project` header for ALL header styles, not just Antigravity. This header caused 403 errors on Daily/Prod endpoints when the user's GCP project lacked Cloud Code API - **#370 / #336**: Account deletion now persists correctly. Root cause: `saveAccounts()` merged deleted accounts back from disk. Fixed by introducing `saveAccountsReplace()` for destructive writes and syncing in-memory state immediately - **#381**: Disabled accounts no longer selected via sticky index. `getCurrentAccountForFamily()` now skips disabled accounts and advances the active index - **#384**: `google_search` tool no longer returns empty citations when using `gemini-3-flash`. Search model switched to `gemini-2.5-flash` - **#377**: Configure models flow now respects existing `opencode.jsonc` files instead of creating duplicate `opencode.json` - **Excessive Disk Writes** - Fixed project context auth updates causing 3000+ writes/sec during streaming. Changed from reference equality to value comparison on auth tokens and added throttled saves. Prevents SSD wear on macOS - **Fingerprint Alignment** - Force-regenerated fingerprints to match current Antigravity Manager behavior, fixing `ideType` and stripping stale client metadata fields ### Removed - **Extra Outgoing Headers** - `X-Goog-Api-Client`, `Client-Metadata`, `X-Goog-QuotaUser`, `X-Client-Device-Id` no longer sent on content requests - **Fingerprint Metadata Fields** - `osVersion`, `arch`, `sqmId` removed from fingerprint client metadata - **`updateFingerprintVersion` Helper** - Removed from accounts module (fingerprint version rewriting no longer needed) ### Documentation - **AGENTS.md** expanded with detailed architecture, code style, and fingerprint system documentation - **README.md**, **CONFIGURATION.md**, **MULTI-ACCOUNT.md** updated to reflect deprecated `quota_fallback` and automatic Gemini pool fallback behavior - **`antigravity.schema.json`** marks `quota_fallback` as deprecated/ignored ## [1.4.5] - 2026-02-05 ### Added - **Configure Models Menu Action** - Auth login menu now includes a "Configure models" action that writes plugin model definitions directly into `opencode.json`, making setup easier for new users - **`cli_first` Config Option** - New configuration option to route Gemini models to Gemini CLI quota first, useful for users who want to preserve Antigravity quota for Claude models - **`toast_scope` Configuration** - Control toast visibility per session with `toast_scope: "root_only"` to suppress toasts in subagent sessions - **Soft Quota Protection** - Skip accounts over 90% usage threshold to prevent Google penalties, with configurable `soft_quota_threshold_percent` and wait/retry behavior - **Gemini CLI Quota Management** - Enhanced quota display with dual quota pool support (Antigravity + Gemini CLI) - **`OPENCODE_CONFIG_DIR` Environment Variable** - Custom config location support for non-standard setups - **`quota_refresh_interval_minutes`** - Background quota cache refresh (default 15 minutes) - **`soft_quota_cache_ttl_minutes`** - Cache freshness control for soft quota checks ### Changed - **Model Naming and Routing** - Documented antigravity-prefixed model names and automatic mapping to CLI preview names (e.g., `antigravity-gemini-3-flash` → `gemini-3-flash-preview`) - **Antigravity-First Quota Strategy** - Exhausts Antigravity quota across ALL accounts before falling back to Gemini CLI quota (previously per-account) - **Quota Routing Respects `cli_first`** - Fallback behavior updated to respect `cli_first` preference - **Config Directory Resolution** - Now prioritizes `OPENCODE_CONFIG_DIR` environment variable - **Enhanced Debug Logging** - Process ID included for better traceability across concurrent sessions - **Improved Quota Group Resolution** - More consistent quota management with `resolveQuotaGroup` function ### Fixed - **#337**: Skip disabled accounts in proactive token refresh - **#233**: Skip sandbox endpoints for Gemini CLI models (fixes 404/403 cascade) - **Windows Config Auto-Migration**: Automatically migrates config from `%APPDATA%\opencode\` to `~/.config/opencode/` - **Root Session Detection**: Reset `isChildSession` flag correctly for root sessions - **Stale Quota Cache**: Prevent spin loop on stale quota cache - **Quota Group Default**: Fix quota group selection defaulting to `gemini-pro` when model is null ### Removed - **Fingerprint Headers for Gemini CLI** - Removed fingerprint headers from Gemini CLI model requests to align with official behavior - **`web_search` Configuration Leftovers** - Cleaned up remaining `web_search` config remnants from schema ### Documentation - Updated README with model configuration options and simplified setup instructions - Updated MODEL-VARIANTS.md with Antigravity model names and configuration guidance - Updated CONFIGURATION.md to clarify `quota_fallback` behavior across accounts - Updated MULTI-ACCOUNT.md with dual quota pool and fallback flow details --- ## [1.3.2] - 2026-01-27 ### Added - **Quota check and account management in auth login** - Added new `--quota` and `--manage` options to the `auth login` command for checking account quota status and managing accounts directly from the CLI ([#284](https://github.com/NoeFabris/opencode-antigravity-auth/issues/284)) - **Request timing jitter** - Added configurable random delay to requests to reduce detection patterns and improve rate limit resilience. Requests now include small random timing variations - **Header randomization for fingerprint diversity** - Headers are now randomized to create more diverse fingerprints, reducing the likelihood of requests being grouped and rate-limited together - **Per-account fingerprint persistence** - Fingerprints are now persisted per-account in storage, allowing consistent identity across sessions and enabling fingerprint history tracking - Added fingerprint restore operations to AccountManager - Extended per-account fingerprint history for better tracking - Fingerprint now shown in debug output - **Scheduling mode configuration** - Added new scheduling modes including `cache-first` mode that prioritizes accounts with cached tokens, reducing authentication overhead - **Failure count TTL expiration** - Account failure counts now expire after a configurable time period, allowing accounts to naturally recover from temporary issues - **Exponential backoff for 503/529 errors** - Implemented exponential backoff with jitter for capacity-related errors, matching behavior of Antigravity-Manager ### Changed - **Increased MODEL_CAPACITY backoff to 45s with jitter** - Extended the base backoff time for model capacity errors from previous values to 45 seconds, with added jitter to prevent thundering herd issues - **Regenerate fingerprint after capacity retry exhaustion** - When all capacity retries are exhausted, the fingerprint is now regenerated to potentially get assigned to a different backend partition - **Enhanced duration parsing for Go format** - Improved parsing of duration strings to handle Go-style duration formats (e.g., `1h30m`) used in some API responses ### Fixed - **Prevent toast spam for rate limit warnings** - Added 5-second debounce for rate limit warning toasts to prevent notification flooding when multiple requests hit rate limits simultaneously ([#286](https://github.com/NoeFabris/opencode-antigravity-auth/issues/286)) - **`getEnabledAccounts` now treats undefined as enabled** - Fixed issue where accounts without an explicit `enabled` field were incorrectly filtered out. Accounts now default to enabled when the field is undefined - **Show correct position in account toast for enabled accounts** - Fixed the account position indicator in toast notifications to only count enabled accounts, showing accurate position like "Account 2/5" instead of including disabled accounts - **Filter disabled accounts in all selection methods** - Ensured disabled accounts are properly excluded from all account selection strategies (round-robin, least-used, random, etc.) - **Robust handling for capacity/5xx errors** - Implemented comprehensive retry logic for model capacity and server errors, achieving parity with Antigravity-Manager's behavior - Reordered parsing logic to prioritize capacity checks - Fixed loop retry logic to prevent state pollution - Added capacity retry limit to prevent infinite loops ([#263](https://github.com/NoeFabris/opencode-antigravity-auth/issues/263)) - **Fixed @opencode-ai/plugin dependency location** - Moved `@opencode-ai/plugin` from devDependencies to dependencies section, fixing runtime errors when the plugin was installed without dev dependencies ### Removed - **Removed deprecated `web_search` configuration** - The deprecated `web_search.default_mode` and `web_search.grounding_threshold` configuration options have been fully removed. Use the `google_search` tool instead (introduced in 1.3.1) ## [1.3.1] - 2026-01-21 ### Added - **New `google_search` tool for web search** - Implements Google Search grounding as a callable tool that the model can invoke explicitly - Makes separate API calls with only `{ googleSearch: {} }` tool, avoiding Gemini API limitation where grounding tools cannot be combined with function declarations - Returns formatted markdown with search results, sources with URLs, and search queries used - Supports optional URL analysis via `urlContext` when URLs are provided - Configurable thinking mode (deep vs fast) for search operations - Uses `gemini-3-flash` model for fast, cost-effective search operations ### Changed - Upgraded to Zod v4 and adjusted schema generation for compatibility - **Deprecated `web_search` config** - The `web_search.default_mode` and `web_search.grounding_threshold` config options are now deprecated. Google Search is now implemented as a dedicated tool rather than automatic grounding injection ### Fixed - **`keep_thinking=true` now works without debug mode** - Fixed Claude multi-turn conversations failing with "Failed to process error response" when `keep_thinking=true` after tool calls, unless debug mode was enabled - Root cause: `filterContentArray` trusted any signature >= 50 chars for last assistant messages, but Claude returns its own signatures that Antigravity doesn't recognize - Fix: Now verifies signatures against our cache via `isOurCachedSignature()` before passing through. Foreign/missing signatures get replaced with `SKIP_THOUGHT_SIGNATURE` sentinel - Why debug worked: Debug mode injects synthetic thinking with no signature, triggering sentinel injection correctly - **Fixed tool calls failing for tools with no parameters** - Tools like `hive_plan_read`, `hive_status`, and `hive_feature_list` that have no required parameters would fail with Zod validation error `state.input: expected record, received undefined` - Root cause: When Claude calls a tool with no parameters, it returns `functionCall` without an `args` field. The response transformation only processed parts where `functionCall.args` was defined, leaving `args` as `undefined` - Fix: Changed condition to handle all `functionCall` parts, defaulting `args` to `{}` when missing, ensuring opencode's `state.input` always receives a valid record - **Auth headers aligned with official Gemini CLI** - Updated authentication headers to match the official Antigravity/Gemini CLI behavior, reducing "account ineligible" errors and potential bans ([#178](https://github.com/NoeFabris/opencode-antigravity-auth/issues/178)) - `GEMINI_CLI_HEADERS["User-Agent"]`: `9.15.1` → `10.3.0` - `GEMINI_CLI_HEADERS["X-Goog-Api-Client"]`: `gl-node/22.17.0` → `gl-node/22.18.0` - `ANTIGRAVITY_HEADERS["User-Agent"]`: Updated to full Chrome/Electron user agent string - Token exchange now includes `Accept`, `Accept-Encoding`, `User-Agent`, `X-Goog-Api-Client` headers - Userinfo fetch now includes `User-Agent`, `X-Goog-Api-Client` headers - `fetchProjectID` now uses centralized constants instead of hardcoded strings - **`quiet_mode` now properly suppresses all toast notifications** - Fixed `quiet_mode: true` in `antigravity.json` not suppressing "Status dialog dismissed" and other toast notifications ([#207](https://github.com/NoeFabris/opencode-antigravity-auth/issues/207)) - Root cause: The `showToast` helper function didn't check `quietMode`, and only some call sites had manual `!quietMode &&` guards - Fix: Moved `quietMode` check inside `showToast` helper so all toasts are automatically suppressed when `quiet_mode: true` ### Removed - **Removed automatic `googleSearch` injection** - Previously attempted to inject `{ googleSearch: {} }` into all Gemini requests, which never worked due to API limitations. Now uses the explicit tool approach instead ## [1.3.0] - Previous Release See [releases](https://github.com/NoeFabris/opencode-antigravity-auth/releases) for previous versions. ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2025 Jens 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. ================================================ FILE: README.md ================================================ # Antigravity + Gemini CLI OAuth Plugin for Opencode [![npm version](https://img.shields.io/npm/v/opencode-antigravity-auth.svg)](https://www.npmjs.com/package/opencode-antigravity-auth) [![npm beta](https://img.shields.io/npm/v/opencode-antigravity-auth/beta.svg?label=beta)](https://www.npmjs.com/package/opencode-antigravity-auth) [![npm downloads](https://img.shields.io/npm/dw/opencode-antigravity-auth.svg)](https://www.npmjs.com/package/opencode-antigravity-auth) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) [![X (Twitter)](https://img.shields.io/badge/X-@dopesalmon-000000?style=flat&logo=x)](https://x.com/dopesalmon) Enable Opencode to authenticate against **Antigravity** (Google's IDE) via OAuth so you can use Antigravity rate limits and access models like `gemini-3.1-pro` and `claude-opus-4-6-thinking` with your Google credentials. ## What You Get - **Claude Opus 4.6, Sonnet 4.6** and **Gemini 3.1 Pro/Flash** via Google OAuth - **Multi-account support** — add multiple Google accounts, auto-rotates when rate-limited - **Dual quota system** — access both Antigravity and Gemini CLI quotas from one plugin - **Thinking models** — extended thinking for Claude and Gemini 3 with configurable budgets - **Google Search grounding** — enable web search for Gemini models (auto or always-on) - **Auto-recovery** — handles session errors and tool failures automatically - **Plugin compatible** — works alongside other OpenCode plugins (oh-my-opencode, dcp, etc.) ---
⚠️ Terms of Service Warning — Read Before Installing > [!CAUTION] > Using this plugin (and any proxy for antgravity) violate Google's Terms of Service. A number of users have reported their Google accounts being **banned** or **shadow-banned** (restricted access without explicit notification). > > **By using this plugin, you acknowledge:** > - This is an unofficial tool not endorsed by Google > - Your account may be suspended or permanently banned > - You assume all risks associated with using this plugin >
--- ## Installation
For Humans **Option A: Let an LLM do it** Paste this into any LLM agent (Claude Code, OpenCode, Cursor, etc.): ``` Install the opencode-antigravity-auth plugin and add the Antigravity model definitions to ~/.config/opencode/opencode.json by following: https://raw.githubusercontent.com/NoeFabris/opencode-antigravity-auth/dev/README.md ``` **Option B: Manual setup** 1. **Add the plugin** to `~/.config/opencode/opencode.json`: ```json { "plugin": ["opencode-antigravity-auth@latest"] } ``` > Want bleeding-edge features? Use `opencode-antigravity-auth@beta` instead. 2. **Login** with your Google account: ```bash opencode auth login ``` 3. **Add models** — choose one: - Run `opencode auth login` → Google → OAuth with Google (Antigravity) → select **"Configure models in opencode.json"** (auto-configures all models) - Or manually copy the [full configuration](#models) below 4. **Use it:** ```bash opencode run "Hello" --model=google/antigravity-claude-opus-4-6-thinking --variant=max ```
For LLM Agents ### Step-by-Step Instructions 1. Edit the OpenCode configuration file at `~/.config/opencode/opencode.json` > **Note**: This path works on all platforms. On Windows, `~` resolves to your user home directory (e.g., `C:\Users\YourName`). 2. Add the plugin to the `plugin` array 3. Add the model definitions from the [Full models configuration](#models) section 4. Set `provider` to `"google"` and choose a model ### Verification ```bash opencode run "Hello" --model=google/antigravity-claude-opus-4-6-thinking --variant=max ```
--- ## Models ### Model Reference **Antigravity quota** (default routing for Claude and Gemini): | Model | Variants | Notes | |-------|----------|-------| | `antigravity-gemini-3-pro` | low, high | Gemini 3 Pro with thinking | | `antigravity-gemini-3.1-pro` | low, high | Gemini 3.1 Pro with thinking (rollout-dependent) | | `antigravity-gemini-3-flash` | minimal, low, medium, high | Gemini 3 Flash with thinking | | `antigravity-claude-sonnet-4-6` | — | Claude Sonnet 4.6 | | `antigravity-claude-opus-4-6-thinking` | low, max | Claude Opus 4.6 with extended thinking | **Gemini CLI quota** (separate from Antigravity; used when `cli_first` is true or as fallback): | Model | Notes | |-------|-------| | `gemini-2.5-flash` | Gemini 2.5 Flash | | `gemini-2.5-pro` | Gemini 2.5 Pro | | `gemini-3-flash-preview` | Gemini 3 Flash (preview) | | `gemini-3-pro-preview` | Gemini 3 Pro (preview) | | `gemini-3.1-pro-preview` | Gemini 3.1 Pro (preview, rollout-dependent) | | `gemini-3.1-pro-preview-customtools` | Gemini 3.1 Pro Preview Custom Tools (preview, rollout-dependent) | > **Routing Behavior:** > - **Antigravity-first (default):** Gemini models use Antigravity quota across accounts. > - **CLI-first (`cli_first: true`):** Gemini models use Gemini CLI quota first. > - When a Gemini quota pool is exhausted, the plugin automatically falls back to the other pool. > - Claude and image models always use Antigravity. > Model names are automatically transformed for the target API (e.g., `antigravity-gemini-3-flash` → `gemini-3-flash-preview` for CLI). **Using variants:** ```bash opencode run "Hello" --model=google/antigravity-claude-opus-4-6-thinking --variant=max ``` For details on variant configuration and thinking levels, see [docs/MODEL-VARIANTS.md](docs/MODEL-VARIANTS.md).
Full models configuration (copy-paste ready) Add this to your `~/.config/opencode/opencode.json`: ```json { "$schema": "https://opencode.ai/config.json", "plugin": ["opencode-antigravity-auth@latest"], "provider": { "google": { "models": { "antigravity-gemini-3-pro": { "name": "Gemini 3 Pro (Antigravity)", "limit": { "context": 1048576, "output": 65535 }, "modalities": { "input": ["text", "image", "pdf"], "output": ["text"] }, "variants": { "low": { "thinkingLevel": "low" }, "high": { "thinkingLevel": "high" } } }, "antigravity-gemini-3.1-pro": { "name": "Gemini 3.1 Pro (Antigravity)", "limit": { "context": 1048576, "output": 65535 }, "modalities": { "input": ["text", "image", "pdf"], "output": ["text"] }, "variants": { "low": { "thinkingLevel": "low" }, "high": { "thinkingLevel": "high" } } }, "antigravity-gemini-3-flash": { "name": "Gemini 3 Flash (Antigravity)", "limit": { "context": 1048576, "output": 65536 }, "modalities": { "input": ["text", "image", "pdf"], "output": ["text"] }, "variants": { "minimal": { "thinkingLevel": "minimal" }, "low": { "thinkingLevel": "low" }, "medium": { "thinkingLevel": "medium" }, "high": { "thinkingLevel": "high" } } }, "antigravity-claude-sonnet-4-6": { "name": "Claude Sonnet 4.6 (Antigravity)", "limit": { "context": 200000, "output": 64000 }, "modalities": { "input": ["text", "image", "pdf"], "output": ["text"] } }, "antigravity-claude-opus-4-6-thinking": { "name": "Claude Opus 4.6 Thinking (Antigravity)", "limit": { "context": 200000, "output": 64000 }, "modalities": { "input": ["text", "image", "pdf"], "output": ["text"] }, "variants": { "low": { "thinkingConfig": { "thinkingBudget": 8192 } }, "max": { "thinkingConfig": { "thinkingBudget": 32768 } } } }, "gemini-2.5-flash": { "name": "Gemini 2.5 Flash (Gemini CLI)", "limit": { "context": 1048576, "output": 65536 }, "modalities": { "input": ["text", "image", "pdf"], "output": ["text"] } }, "gemini-2.5-pro": { "name": "Gemini 2.5 Pro (Gemini CLI)", "limit": { "context": 1048576, "output": 65536 }, "modalities": { "input": ["text", "image", "pdf"], "output": ["text"] } }, "gemini-3-flash-preview": { "name": "Gemini 3 Flash Preview (Gemini CLI)", "limit": { "context": 1048576, "output": 65536 }, "modalities": { "input": ["text", "image", "pdf"], "output": ["text"] } }, "gemini-3-pro-preview": { "name": "Gemini 3 Pro Preview (Gemini CLI)", "limit": { "context": 1048576, "output": 65535 }, "modalities": { "input": ["text", "image", "pdf"], "output": ["text"] } }, "gemini-3.1-pro-preview": { "name": "Gemini 3.1 Pro Preview (Gemini CLI)", "limit": { "context": 1048576, "output": 65535 }, "modalities": { "input": ["text", "image", "pdf"], "output": ["text"] } }, "gemini-3.1-pro-preview-customtools": { "name": "Gemini 3.1 Pro Preview Custom Tools (Gemini CLI)", "limit": { "context": 1048576, "output": 65535 }, "modalities": { "input": ["text", "image", "pdf"], "output": ["text"] } } } } } } ``` > **Backward Compatibility:** Legacy model names with `antigravity-` prefix (e.g., `antigravity-gemini-3-flash`) still work. The plugin automatically handles model name transformation for both Antigravity and Gemini CLI APIs.
--- ## Multi-Account Setup Add multiple Google accounts for higher combined quotas. The plugin automatically rotates between accounts when one is rate-limited. ```bash opencode auth login # Run again to add more accounts ``` **Account management options (via `opencode auth login`):** - **Configure models** — Auto-configure all plugin models in opencode.json - **Check quotas** — View remaining API quota for each account - **Manage accounts** — Enable/disable specific accounts for rotation For details on load balancing, dual quota pools, and account storage, see [docs/MULTI-ACCOUNT.md](docs/MULTI-ACCOUNT.md). --- ## Troubleshoot > **Quick Reset**: Most issues can be resolved by deleting `~/.config/opencode/antigravity-accounts.json` and running `opencode auth login` again. ### Configuration Path (All Platforms) OpenCode uses `~/.config/opencode/` on **all platforms** including Windows. | File | Path | |------|------| | Main config | `~/.config/opencode/opencode.json` | | Accounts | `~/.config/opencode/antigravity-accounts.json` | | Plugin config | `~/.config/opencode/antigravity.json` | | Debug logs | `~/.config/opencode/antigravity-logs/` | > **Windows users**: `~` resolves to your user home directory (e.g., `C:\Users\YourName`). Do NOT use `%APPDATA%`. > **Custom path**: Set `OPENCODE_CONFIG_DIR` environment variable to use a custom location. > **Windows migration**: If upgrading from plugin v1.3.x or earlier, the plugin will automatically find your existing config in `%APPDATA%\opencode\` and use it. New installations use `~/.config/opencode/`. --- ### Multi-Account Auth Issues If you encounter authentication issues with multiple accounts: 1. Delete the accounts file: ```bash rm ~/.config/opencode/antigravity-accounts.json ``` 2. Re-authenticate: ```bash opencode auth login ``` --- ### 403 Permission Denied (`rising-fact-p41fc`) **Error:** ``` Permission 'cloudaicompanion.companions.generateChat' denied on resource '//cloudaicompanion.googleapis.com/projects/rising-fact-p41fc/locations/global' ``` **Cause:** Plugin falls back to a default project ID when no valid project is found. This works for Antigravity but fails for Gemini CLI models. **Solution:** 1. Go to [Google Cloud Console](https://console.cloud.google.com/) 2. Create or select a project 3. Enable the **Gemini for Google Cloud API** (`cloudaicompanion.googleapis.com`) 4. Add `projectId` to your accounts file: ```json { "accounts": [ { "email": "your@email.com", "refreshToken": "...", "projectId": "your-project-id" } ] } ``` > **Note**: Do this for each account in a multi-account setup. --- ### Gemini Model Not Found Add this to your `google` provider config: ```json { "provider": { "google": { "npm": "@ai-sdk/google", "models": { ... } } } } ``` --- ### Gemini 3 Models 400 Error ("Unknown name 'parameters'") **Error:** ``` Invalid JSON payload received. Unknown name "parameters" at 'request.tools[0]' ``` **Causes:** - Tool schema incompatibility with Gemini's strict protobuf validation - MCP servers with malformed schemas - Plugin version regression **Solutions:** 1. **Update to latest beta:** ```json { "plugin": ["opencode-antigravity-auth@beta"] } ``` 2. **Disable MCP servers** one-by-one to find the problematic one 3. **Add npm override:** ```json { "provider": { "google": { "npm": "@ai-sdk/google" } } } ``` --- ### MCP Servers Causing Errors Some MCP servers have schemas incompatible with Antigravity's strict JSON format. **Common symptom:** ```bash Invalid function name must start with a letter or underscore ``` Sometimes it shows up as: ```bash GenerateContentRequest.tools[0].function_declarations[12].name: Invalid function name must start with a letter or underscore ``` This usually means an MCP tool name starts with a number (for example, a 1mcp key like `1mcp_*`). Rename the MCP key to start with a letter (e.g., `gw`) or disable that MCP entry for Antigravity models. **Diagnosis:** 1. Disable all MCP servers in your config 2. Enable one-by-one until error reappears 3. Report the specific MCP in a [GitHub issue](https://github.com/NoeFabris/opencode-antigravity-auth/issues) --- ### "All Accounts Rate-Limited" (But Quota Available) **Cause:** Cascade bug in `clearExpiredRateLimits()` in hybrid mode (fixed in recent beta). **Solutions:** 1. Update to latest beta version 2. If persists, delete accounts file and re-authenticate 3. Try switching `account_selection_strategy` to `"sticky"` in `antigravity.json` --- ### Session Recovery If you encounter errors during a session: 1. Type `continue` to trigger the recovery mechanism 2. If blocked, use `/undo` to revert to pre-error state 3. Retry the operation --- ### Using with Oh-My-OpenCode **Important:** Disable the built-in Google auth to prevent conflicts: ```json // ~/.config/opencode/oh-my-opencode.json { "google_auth": false, "agents": { "frontend-ui-ux-engineer": { "model": "google/antigravity-gemini-3-pro" }, "document-writer": { "model": "google/antigravity-gemini-3-flash" } } } ``` --- ### Infinite `.tmp` Files Created **Cause:** When account is rate-limited and plugin retries infinitely, it creates many temp files. **Workaround:** 1. Stop OpenCode 2. Clean up: `rm ~/.config/opencode/*.tmp` 3. Add more accounts or wait for rate limit to expire --- ### OAuth Callback Issues
Safari OAuth Callback Fails (macOS) **Symptoms:** - "fail to authorize" after successful Google login - Safari shows "Safari can't open the page" **Cause:** Safari's "HTTPS-Only Mode" blocks `http://localhost` callback. **Solutions:** 1. **Use Chrome or Firefox** (easiest): Copy the OAuth URL and paste into a different browser. 2. **Disable HTTPS-Only Mode temporarily:** - Safari > Settings (⌘,) > Privacy - Uncheck "Enable HTTPS-Only Mode" - Run `opencode auth login` - Re-enable after authentication
Port Conflict (Address Already in Use) **macOS / Linux:** ```bash # Find process using the port lsof -i :51121 # Kill if stale kill -9 # Retry opencode auth login ``` **Windows (PowerShell):** ```powershell netstat -ano | findstr :51121 taskkill /PID /F opencode auth login ```
Docker / WSL2 / Remote Development OAuth callback requires browser to reach `localhost` on the machine running OpenCode. **WSL2:** - Use VS Code's port forwarding, or - Configure Windows → WSL port forwarding **SSH / Remote:** ```bash ssh -L 51121:localhost:51121 user@remote ``` **Docker / Containers:** - OAuth with localhost redirect doesn't work in containers - Wait 30s for manual URL flow, or use SSH port forwarding
--- ### Configuration Key Typo: `plugin` not `plugins` The correct key is `plugin` (singular): ```json { "plugin": ["opencode-antigravity-auth@beta"] } ``` **Not** `"plugins"` (will cause "Unrecognized key" error). --- ### Migrating Accounts Between Machines When copying `antigravity-accounts.json` to a new machine: 1. Ensure the plugin is installed: `"plugin": ["opencode-antigravity-auth@beta"]` 2. Copy `~/.config/opencode/antigravity-accounts.json` 3. If you get "API key missing" error, the refresh token may be invalid — re-authenticate ## Known Plugin Interactions For details on load balancing, dual quota pools, and account storage, see [docs/MULTI-ACCOUNT.md](docs/MULTI-ACCOUNT.md). --- ## Plugin Compatibility ### @tarquinen/opencode-dcp DCP creates synthetic assistant messages that lack thinking blocks. **List this plugin BEFORE DCP:** ```json { "plugin": [ "opencode-antigravity-auth@latest", "@tarquinen/opencode-dcp@latest" ] } ``` ### oh-my-opencode Disable built-in auth and override agent models in `oh-my-opencode.json`: ```json { "google_auth": false, "agents": { "frontend-ui-ux-engineer": { "model": "google/antigravity-gemini-3-pro" }, "document-writer": { "model": "google/antigravity-gemini-3-flash" }, "multimodal-looker": { "model": "google/antigravity-gemini-3-flash" } } } ``` > **Tip:** When spawning parallel subagents, enable `pid_offset_enabled: true` in `antigravity.json` to distribute sessions across accounts. ### Plugins you don't need - **gemini-auth plugins** — Not needed. This plugin handles all Google OAuth. --- ## Configuration Create `~/.config/opencode/antigravity.json` for optional settings: ```json { "$schema": "https://raw.githubusercontent.com/NoeFabris/opencode-antigravity-auth/main/assets/antigravity.schema.json" } ``` Most users don't need to configure anything — defaults work well. ### Model Behavior | Option | Default | What it does | |--------|---------|-------------- | `keep_thinking` | `false` | Preserve Claude's thinking across turns. **Warning:** enabling may degrade model stability. | | `session_recovery` | `true` | Auto-recover from tool errors | | `cli_first` | `false` | Route Gemini models to Gemini CLI first (Claude and image models stay on Antigravity). | ### Account Rotation | Your Setup | Recommended Config | |------------|-------------------| | **1 account** | `"account_selection_strategy": "sticky"` | | **2-5 accounts** | Default (`"hybrid"`) works great | | **5+ accounts** | `"account_selection_strategy": "round-robin"` | | **Parallel agents** | Add `"pid_offset_enabled": true` | ### Quota Protection | Option | Default | What it does | |--------|---------|--------------| | `soft_quota_threshold_percent` | `90` | Skip account when quota usage exceeds this percentage. Prevents Google from penalizing accounts that fully exhaust quota. Set to `100` to disable. | | `quota_refresh_interval_minutes` | `15` | Background quota refresh interval. After successful API requests, refreshes quota cache if older than this interval. Set to `0` to disable. | | `soft_quota_cache_ttl_minutes` | `"auto"` | How long quota cache is considered fresh. `"auto"` = max(2 × refresh interval, 10 minutes). Set a number (1-120) for fixed TTL. | > **How it works**: Quota cache is refreshed automatically after API requests (when older than `quota_refresh_interval_minutes`) and manually via "Check quotas" in `opencode auth login`. The threshold check uses `soft_quota_cache_ttl_minutes` to determine cache freshness - if cache is older, the account is considered "unknown" and allowed (fail-open). When ALL accounts exceed the threshold, the plugin waits for the earliest quota reset time (like rate limit behavior). If wait time exceeds `max_rate_limit_wait_seconds`, it errors immediately. ### Rate Limit Scheduling Control how the plugin handles rate limits: | Option | Default | What it does | |--------|---------|--------------| | `scheduling_mode` | `"cache_first"` | `"cache_first"` = wait for same account (preserves prompt cache), `"balance"` = switch immediately, `"performance_first"` = round-robin | | `max_cache_first_wait_seconds` | `60` | Max seconds to wait in cache_first mode before switching accounts | | `failure_ttl_seconds` | `3600` | Reset failure count after this many seconds (prevents old failures from permanently penalizing accounts) | **When to use each mode:** - **cache_first** (default): Best for long conversations. Waits for the same account to recover, preserving your prompt cache. - **balance**: Best for quick tasks. Switches accounts immediately when rate-limited for maximum availability. - **performance_first**: Best for many short requests. Distributes load evenly across all accounts. ### App Behavior | Option | Default | What it does | |--------|---------|--------------| | `quiet_mode` | `false` | Hide toast notifications | | `debug` | `false` | Enable debug file logging (`~/.config/opencode/antigravity-logs/`) | | `debug_tui` | `false` | Show debug logs in the TUI log panel (independent from `debug`) | | `auto_update` | `true` | Auto-update plugin | For all options, see [docs/CONFIGURATION.md](docs/CONFIGURATION.md). **Environment variables:** ```bash OPENCODE_CONFIG_DIR=/path/to/config opencode # Custom config directory OPENCODE_ANTIGRAVITY_DEBUG=1 opencode # Enable debug file logging OPENCODE_ANTIGRAVITY_DEBUG=2 opencode # Verbose debug file logging OPENCODE_ANTIGRAVITY_DEBUG_TUI=1 opencode # Enable TUI log panel debug output ``` --- ## Troubleshooting See the full [Troubleshooting Guide](docs/TROUBLESHOOTING.md) for solutions to common issues including: - Auth problems and token refresh - "Model not found" errors - Session recovery - Gemini CLI permission errors - Safari OAuth issues - Plugin compatibility - Migration guides --- ## Documentation - [Configuration](docs/CONFIGURATION.md) — All configuration options - [Multi-Account](docs/MULTI-ACCOUNT.md) — Load balancing, dual quota pools, account storage - [Model Variants](docs/MODEL-VARIANTS.md) — Thinking budgets and variant system - [Troubleshooting](docs/TROUBLESHOOTING.md) — Common issues and fixes - [Architecture](docs/ARCHITECTURE.md) — How the plugin works - [API Spec](docs/ANTIGRAVITY_API_SPEC.md) — Antigravity API reference --- ## Support If this plugin helps you, consider supporting its maintenance: [![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/S6S81QBOIR) --- ## Credits - [opencode-gemini-auth](https://github.com/jenslys/opencode-gemini-auth) by [@jenslys](https://github.com/jenslys) - [CLIProxyAPI](https://github.com/router-for-me/CLIProxyAPI) ## License MIT License. See [LICENSE](LICENSE) for details.
Legal ### Intended Use - Personal / internal development only - Respect internal quotas and data handling policies - Not for production services or bypassing intended limits ### Warning By using this plugin, you acknowledge: - **Terms of Service risk** — This approach may violate ToS of AI model providers - **Account risk** — Providers may suspend or ban accounts - **No guarantees** — APIs may change without notice - **Assumption of risk** — You assume all legal, financial, and technical risks ### Disclaimer - Not affiliated with Google. This is an independent open-source project. - "Antigravity", "Gemini", "Google Cloud", and "Google" are trademarks of Google LLC.
================================================ FILE: assets/antigravity.schema.json ================================================ { "$schema": "https://json-schema.org/draft/2020-12/schema", "type": "object", "properties": { "$schema": { "type": "string" }, "quiet_mode": { "default": false, "type": "boolean", "description": "Suppress most toast notifications (rate limit, account switching). Recovery toasts always shown." }, "toast_scope": { "default": "root_only", "type": "string", "enum": [ "root_only", "all" ], "description": "Controls which sessions show toast notifications. 'root_only' (default) shows in root session only, 'all' shows in all sessions." }, "debug": { "default": false, "type": "boolean", "description": "Enable debug logging to file." }, "debug_tui": { "default": false, "type": "boolean" }, "log_dir": { "type": "string", "description": "Custom directory for debug logs." }, "keep_thinking": { "default": false, "type": "boolean", "description": "Preserve thinking blocks for Claude models using signature caching. May cause signature errors." }, "session_recovery": { "default": true, "type": "boolean", "description": "Enable automatic session recovery from tool_result_missing errors." }, "auto_resume": { "default": false, "type": "boolean", "description": "Automatically send resume prompt after successful recovery." }, "resume_text": { "default": "continue", "type": "string", "description": "Custom text to send when auto-resuming after recovery." }, "signature_cache": { "type": "object", "properties": { "enabled": { "default": true, "type": "boolean", "description": "Enable disk caching of thinking block signatures." }, "memory_ttl_seconds": { "default": 3600, "type": "number", "minimum": 60, "maximum": 86400, "description": "In-memory TTL in seconds." }, "disk_ttl_seconds": { "default": 172800, "type": "number", "minimum": 3600, "maximum": 604800, "description": "Disk TTL in seconds." }, "write_interval_seconds": { "default": 60, "type": "number", "minimum": 10, "maximum": 600, "description": "Background write interval in seconds." } }, "required": [ "enabled", "memory_ttl_seconds", "disk_ttl_seconds", "write_interval_seconds" ], "additionalProperties": false, "description": "Signature cache configuration for persisting thinking block signatures. Only used when keep_thinking is enabled." }, "empty_response_max_attempts": { "default": 4, "type": "number", "minimum": 1, "maximum": 10, "description": "Maximum retry attempts when Antigravity returns an empty response (no candidates)." }, "empty_response_retry_delay_ms": { "default": 2000, "type": "number", "minimum": 500, "maximum": 10000, "description": "Delay in milliseconds between empty response retries." }, "tool_id_recovery": { "default": true, "type": "boolean", "description": "Enable tool ID orphan recovery. Matches mismatched tool responses by function name or creates placeholders." }, "claude_tool_hardening": { "default": true, "type": "boolean", "description": "Enable tool hallucination prevention for Claude models. Injects parameter signatures and strict usage rules." }, "claude_prompt_auto_caching": { "default": false, "type": "boolean", "description": "Enable Claude prompt auto-caching by adding top-level cache_control when absent." }, "proactive_token_refresh": { "default": true, "type": "boolean", "description": "Enable proactive background token refresh before expiry, ensuring requests never block." }, "proactive_refresh_buffer_seconds": { "default": 1800, "type": "number", "minimum": 60, "maximum": 7200, "description": "Seconds before token expiry to trigger proactive refresh." }, "proactive_refresh_check_interval_seconds": { "default": 300, "type": "number", "minimum": 30, "maximum": 1800, "description": "Interval between proactive refresh checks in seconds." }, "max_rate_limit_wait_seconds": { "default": 300, "type": "number", "minimum": 0, "maximum": 3600 }, "quota_fallback": { "default": false, "type": "boolean", "description": "Deprecated: accepted for backward compatibility but ignored at runtime. Gemini fallback between Antigravity and Gemini CLI is always enabled." }, "cli_first": { "default": false, "type": "boolean", "description": "Prefer gemini-cli routing before Antigravity for Gemini models. When false (default), Antigravity is tried first and gemini-cli is fallback." }, "account_selection_strategy": { "default": "hybrid", "type": "string", "enum": [ "sticky", "round-robin", "hybrid" ] }, "pid_offset_enabled": { "default": false, "type": "boolean" }, "switch_on_first_rate_limit": { "default": true, "type": "boolean" }, "scheduling_mode": { "default": "cache_first", "type": "string", "enum": [ "cache_first", "balance", "performance_first" ], "description": "Rate limit scheduling strategy. 'cache_first' (default) waits for cooldowns, 'balance' distributes across accounts, 'performance_first' picks fastest available." }, "max_cache_first_wait_seconds": { "default": 60, "type": "number", "minimum": 5, "maximum": 300, "description": "Maximum seconds to wait for a rate-limited account in cache_first mode before switching." }, "failure_ttl_seconds": { "default": 3600, "type": "number", "minimum": 60, "maximum": 7200, "description": "Time in seconds before a failed account is eligible for retry." }, "default_retry_after_seconds": { "default": 60, "type": "number", "minimum": 1, "maximum": 300 }, "max_backoff_seconds": { "default": 60, "type": "number", "minimum": 5, "maximum": 300 }, "request_jitter_max_ms": { "default": 0, "type": "number", "minimum": 0, "maximum": 5000, "description": "Maximum random jitter in milliseconds added to outgoing requests to avoid thundering herd." }, "soft_quota_threshold_percent": { "default": 90, "type": "number", "minimum": 1, "maximum": 100, "description": "Percentage of quota usage that triggers soft quota warnings and preemptive account switching." }, "quota_refresh_interval_minutes": { "default": 15, "type": "number", "minimum": 0, "maximum": 60, "description": "Interval in minutes between quota usage checks. Set to 0 to disable periodic checks." }, "soft_quota_cache_ttl_minutes": { "default": "auto", "anyOf": [ { "type": "string", "const": "auto" }, { "type": "number", "minimum": 1, "maximum": 120 } ], "description": "TTL for cached soft quota data. 'auto' (default) calculates from refresh interval, or set a fixed number of minutes." }, "health_score": { "type": "object", "properties": { "initial": { "default": 70, "type": "number", "minimum": 0, "maximum": 100 }, "success_reward": { "default": 1, "type": "number", "minimum": 0, "maximum": 10 }, "rate_limit_penalty": { "default": -10, "type": "number", "minimum": -50, "maximum": 0 }, "failure_penalty": { "default": -20, "type": "number", "minimum": -100, "maximum": 0 }, "recovery_rate_per_hour": { "default": 2, "type": "number", "minimum": 0, "maximum": 20 }, "min_usable": { "default": 50, "type": "number", "minimum": 0, "maximum": 100 }, "max_score": { "default": 100, "type": "number", "minimum": 50, "maximum": 100 } }, "required": [ "initial", "success_reward", "rate_limit_penalty", "failure_penalty", "recovery_rate_per_hour", "min_usable", "max_score" ], "additionalProperties": false }, "token_bucket": { "type": "object", "properties": { "max_tokens": { "default": 50, "type": "number", "minimum": 1, "maximum": 1000 }, "regeneration_rate_per_minute": { "default": 6, "type": "number", "minimum": 0.1, "maximum": 60 }, "initial_tokens": { "default": 50, "type": "number", "minimum": 1, "maximum": 1000 } }, "required": [ "max_tokens", "regeneration_rate_per_minute", "initial_tokens" ], "additionalProperties": false }, "auto_update": { "default": true, "type": "boolean", "description": "Enable automatic plugin updates." } }, "additionalProperties": false } ================================================ FILE: docs/ANTIGRAVITY_API_SPEC.md ================================================ # Antigravity Unified Gateway API Specification **Version:** 1.0 **Last Updated:** December 13, 2025 **Status:** Verified by Direct API Testing --- ## Overview Antigravity is Google's **Unified Gateway API** for accessing multiple AI models (Claude, Gemini, GPT-OSS) through a single, consistent Gemini-style interface. It is NOT the same as Vertex AI's direct model APIs. ### Key Characteristics - **Single API format** for all models (Gemini-style) - **Project-based access** via Google Cloud authentication - **Internal routing** to model backends (Vertex AI for Claude, Gemini API for Gemini) - **Unified response format** (`candidates[]` structure for all models) --- ## Endpoints | Environment | URL | Status | |-------------|-----|--------| | **Daily (Sandbox)** | `https://daily-cloudcode-pa.sandbox.googleapis.com` | ✅ Active | | **Production** | `https://cloudcode-pa.googleapis.com` | ✅ Active | | **Autopush (Sandbox)** | `https://autopush-cloudcode-pa.sandbox.googleapis.com` | ❌ Unavailable | ### API Actions | Action | Path | Description | |--------|------|-------------| | Generate Content | `/v1internal:generateContent` | Non-streaming request | | Stream Generate | `/v1internal:streamGenerateContent?alt=sse` | Streaming (SSE) request | | Load Code Assist | `/v1internal:loadCodeAssist` | Project discovery | | Onboard User | `/v1internal:onboardUser` | User onboarding | --- ## Authentication ### OAuth 2.0 Setup ``` Authorization URL: https://accounts.google.com/o/oauth2/auth Token URL: https://oauth2.googleapis.com/token ``` ### Required 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 ``` ### Required Headers ```http Authorization: Bearer {access_token} Content-Type: application/json User-Agent: antigravity/1.15.8 windows/amd64 X-Goog-Api-Client: google-cloud-sdk vscode_cloudshelleditor/0.1 Client-Metadata: {"ideType":"ANTIGRAVITY","platform":"MACOS","pluginType":"GEMINI"} ``` For streaming requests, also include: ```http Accept: text/event-stream ``` --- ## Available Models | Model Name | Model ID | Type | Status | |------------|----------|------|--------| | Claude Sonnet 4.6 | `claude-sonnet-4-6` | Anthropic | ✅ Verified | | Claude Opus 4.6 Thinking | `claude-opus-4-6-thinking` | Anthropic | ✅ Verified | | Gemini 3 Pro High | `gemini-3-pro-high` | Google | ✅ Verified | | Gemini 3 Pro Low | `gemini-3-pro-low` | Google | ✅ Verified | | GPT-OSS 120B Medium | `gpt-oss-120b-medium` | Other | ✅ Verified | --- ## Request Format ### Basic Structure ```json { "project": "{project_id}", "model": "{model_id}", "request": { "contents": [...], "generationConfig": {...}, "systemInstruction": {...}, "tools": [...] }, "userAgent": "antigravity", "requestId": "{unique_id}" } ``` ### Contents Array (REQUIRED) **⚠️ IMPORTANT: Must use Gemini-style format. Anthropic-style `messages` array is NOT supported.** ```json { "contents": [ { "role": "user", "parts": [ { "text": "Your message here" } ] }, { "role": "model", "parts": [ { "text": "Assistant response" } ] } ] } ``` #### Role Values - `user` - Human/user messages - `model` - Assistant responses (NOT `assistant`) ### Generation Config ```json { "generationConfig": { "maxOutputTokens": 1000, "temperature": 0.7, "topP": 0.95, "topK": 40, "stopSequences": ["STOP"], "thinkingConfig": { "thinkingBudget": 8000, "includeThoughts": true } } } ``` | Field | Type | Description | |-------|------|-------------| | `maxOutputTokens` | number | Maximum tokens in response | | `temperature` | number | Randomness (0.0 - 2.0) | | `topP` | number | Nucleus sampling threshold | | `topK` | number | Top-K sampling | | `stopSequences` | string[] | Stop generation triggers | | `thinkingConfig` | object | Extended thinking config | ### System Instructions **⚠️ Must be an object with `parts`, NOT a plain string.** ```json // ✅ CORRECT { "systemInstruction": { "parts": [ { "text": "You are a helpful assistant." } ] } } // ❌ WRONG - Will return 400 error { "systemInstruction": "You are a helpful assistant." } ``` ### Tools / Function Calling ```json { "tools": [ { "functionDeclarations": [ { "name": "get_weather", "description": "Get weather for a location", "parameters": { "type": "object", "properties": { "location": { "type": "string", "description": "City name" } }, "required": ["location"] } } ] } ] } ``` ### Google Search Grounding Gemini models support Google Search grounding, but **it cannot be combined with function declarations** in the same request. This plugin implements a dedicated `google_search` tool that makes separate API calls. #### How the `google_search` Tool Works The model can call `google_search(query, urls?, thinking?)` which: 1. Makes a **separate API call** to Antigravity with only `{ googleSearch: {} }` (no function declarations) 2. Parses the `groundingMetadata` from the response 3. Returns formatted markdown with sources and citations **Tool Parameters:** | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `query` | string | ✅ | The search query or question | | `urls` | string[] | ❌ | URLs to analyze (adds `urlContext` tool) | | `thinking` | boolean | ❌ | Enable deep thinking (default: true) | **Example Response:** ```markdown ## Search Results Spain won Euro 2024, defeating England 2-1 in the final... ### Sources - [UEFA Euro 2024](https://uefa.com/...) - [Al Jazeera](https://aljazeera.com/...) ### Search Queries Used - "UEFA Euro 2024 winner" ``` #### Raw API Format (for reference) The underlying API uses these tool formats: **New API (Gemini 2.0+ / Gemini 3):** ```json { "tools": [ { "googleSearch": {} } ] } ``` **Legacy API (Gemini 1.5 only - deprecated):** ```json { "tools": [ { "googleSearchRetrieval": { "dynamicRetrievalConfig": { "mode": "MODE_DYNAMIC", "dynamicThreshold": 0.3 } } } ] } ``` **Response includes `groundingMetadata`:** ```json { "groundingMetadata": { "webSearchQueries": ["query1", "query2"], "searchEntryPoint": { "renderedContent": "..." }, "groundingChunks": [{ "web": { "uri": "...", "title": "..." } }], "groundingSupports": [{ "segment": {...}, "groundingChunkIndices": [...] }] } } ``` > **Important:** `googleSearch` and `urlContext` tools **cannot be combined with `functionDeclarations`** in the same request. This is why the plugin uses a separate API call. ``` ### Function Name Rules | Rule | Description | |------|-------------| | First character | Must be a letter (a-z, A-Z) or underscore (_) | | Allowed characters | `a-zA-Z0-9`, underscores (`_`), dots (`.`), colons (`:`), dashes (`-`) | | Max length | 64 characters | | Not allowed | Slashes (`/`), spaces, other special characters | **Examples:** - ✅ `get_weather` - Valid - ✅ `mcp:mongodb.query` - Valid (colons and dots allowed) - ✅ `read-file` - Valid (dashes allowed) - ❌ `mcp/query` - Invalid (slashes not allowed) - ❌ `123_tool` - Invalid (must start with letter or underscore) ### JSON Schema Support | Feature | Status | Notes | |---------|--------|-------| | `type` | ✅ Supported | `object`, `string`, `number`, `integer`, `boolean`, `array` | | `properties` | ✅ Supported | Object properties | | `required` | ✅ Supported | Required fields array | | `description` | ✅ Supported | Field descriptions | | `enum` | ✅ Supported | Enumerated values | | `items` | ✅ Supported | Array item schema | | `anyOf` | ✅ Supported | Converted to `any_of` internally | | `allOf` | ✅ Supported | Converted to `all_of` internally | | `oneOf` | ✅ Supported | Converted to `one_of` internally | | `additionalProperties` | ✅ Supported | Additional properties schema | | `const` | ❌ NOT Supported | Use `enum: [value]` instead | | `$ref` | ❌ NOT Supported | Inline the schema instead | | `$defs` / `definitions` | ❌ NOT Supported | Inline definitions instead | | `$schema` | ❌ NOT Supported | Strip from schema | | `$id` | ❌ NOT Supported | Strip from schema | | `default` | ❌ NOT Supported | Strip from schema | | `examples` | ❌ NOT Supported | Strip from schema | | `title` (nested) | ⚠️ Caution | May cause issues in nested objects | **⚠️ IMPORTANT:** The following features will cause a 400 error if sent to the API: - `const` - Convert to `enum: [value]` instead - `$ref` / `$defs` - Inline the schema definitions - `$schema` / `$id` - Strip these metadata fields - `default` / `examples` - Strip these documentation fields ```json // ❌ WRONG - Will return 400 error { "type": { "const": "email" } } // ✅ CORRECT - Use enum instead { "type": { "enum": ["email"] } } ``` **Note:** The plugin automatically handles these conversions via the `schema-transform.ts` module. --- ## Response Format ### Non-Streaming Response ```json { "response": { "candidates": [ { "content": { "role": "model", "parts": [ { "text": "Response text here" } ] }, "finishReason": "STOP" } ], "usageMetadata": { "promptTokenCount": 16, "candidatesTokenCount": 4, "totalTokenCount": 20 }, "modelVersion": "claude-sonnet-4-6", "responseId": "msg_vrtx_..." }, "traceId": "abc123..." } ``` ### Streaming Response (SSE) Content-Type: `text/event-stream` ``` data: {"response": {"candidates": [{"content": {"role": "model", "parts": [{"text": "Hello"}]}}], "usageMetadata": {...}, "modelVersion": "...", "responseId": "..."}, "traceId": "..."} data: {"response": {"candidates": [{"content": {"role": "model", "parts": [{"text": " world"}]}, "finishReason": "STOP"}], "usageMetadata": {...}}, "traceId": "..."} ``` ### Response Fields | Field | Description | |-------|-------------| | `response.candidates` | Array of response candidates | | `response.candidates[].content.role` | Always `"model"` | | `response.candidates[].content.parts` | Array of content parts | | `response.candidates[].finishReason` | `STOP`, `MAX_TOKENS`, `OTHER` | | `response.usageMetadata.promptTokenCount` | Input tokens | | `response.usageMetadata.candidatesTokenCount` | Output tokens | | `response.usageMetadata.totalTokenCount` | Total tokens | | `response.usageMetadata.thoughtsTokenCount` | Thinking tokens (Gemini) | | `response.modelVersion` | Actual model used | | `response.responseId` | Request ID (format varies by model) | | `traceId` | Trace ID for debugging | ### Response ID Formats | Model Type | Format | Example | |------------|--------|---------| | Claude | `msg_vrtx_...` | `msg_vrtx_01UDKZG8PWPj9mjajje8d7u7` | | Gemini | Base64-like | `ypM9abPqFKWl0-kPvamgqQw` | | GPT-OSS | Base64-like | `y5M9aZaSKq6z2roPoJ7pEA` | --- ## Function Call Response When the model wants to call a function: ```json { "response": { "candidates": [ { "content": { "role": "model", "parts": [ { "functionCall": { "name": "get_weather", "args": { "location": "Paris" }, "id": "toolu_vrtx_01PDbPTJgBJ3AJ8BCnSXvUqk" } } ] }, "finishReason": "OTHER" } ] } } ``` ### Providing Function Results ```json { "contents": [ { "role": "user", "parts": [{ "text": "What's the weather?" }] }, { "role": "model", "parts": [{ "functionCall": { "name": "get_weather", "args": {...}, "id": "..." } }] }, { "role": "user", "parts": [{ "functionResponse": { "name": "get_weather", "id": "...", "response": { "temperature": "22C" } } }] } ] } ``` --- ## Thinking / Extended Reasoning ### Thinking Config For thinking-capable models (`*-thinking`), use: ```json { "generationConfig": { "maxOutputTokens": 10000, "thinkingConfig": { "thinkingBudget": 8000, "includeThoughts": true } } } ``` **⚠️ IMPORTANT: `maxOutputTokens` must be GREATER than `thinkingBudget`** ### Thinking Response (Gemini) Gemini models return thinking with signatures: ```json { "parts": [ { "thoughtSignature": "ErADCq0DAXLI2nx...", "text": "Let me think about this..." }, { "text": "The answer is..." } ] } ``` ### Thinking Response (Claude) Claude thinking models may include `thought: true` parts: ```json { "parts": [ { "thought": true, "text": "Reasoning process...", "thoughtSignature": "..." }, { "text": "Final answer..." } ] } ``` --- ## Error Responses ### Error Structure ```json { "error": { "code": 400, "message": "Error description", "status": "INVALID_ARGUMENT", "details": [...] } } ``` ### Common Error Codes | Code | Status | Description | |------|--------|-------------| | 400 | `INVALID_ARGUMENT` | Invalid request format | | 401 | `UNAUTHENTICATED` | Invalid/expired token | | 403 | `PERMISSION_DENIED` | No access to resource | | 404 | `NOT_FOUND` | Model not found | | 429 | `RESOURCE_EXHAUSTED` | Rate limit exceeded | ### Rate Limit Response ```json { "error": { "code": 429, "message": "You have exhausted your capacity on this model. Your quota will reset after 3s.", "status": "RESOURCE_EXHAUSTED", "details": [ { "@type": "type.googleapis.com/google.rpc.RetryInfo", "retryDelay": "3.957525076s" } ] } } ``` --- ## NOT Supported The following Anthropic/Vertex AI features are **NOT supported**: | Feature | Error | |---------|-------| | `anthropic_version` | Unknown field | | `messages` array | Unknown field | | `max_tokens` | Unknown field | | Plain string `systemInstruction` | Invalid value | | `system_instruction` (snake_case at root) | Unknown field | | JSON Schema `const` | Unknown field (use `enum: [value]`) | | JSON Schema `$ref` | Not supported (inline instead) | | JSON Schema `$defs` | Not supported (inline instead) | | Tool names with `/` | Invalid (use `_` or `:` instead) | | Tool names starting with digit | Invalid (must start with letter/underscore) | --- ## Complete Request Example ```json { "project": "my-project-id", "model": "claude-sonnet-4-6", "request": { "contents": [ { "role": "user", "parts": [ { "text": "Hello, how are you?" } ] } ], "systemInstruction": { "parts": [ { "text": "You are a helpful assistant." } ] }, "generationConfig": { "maxOutputTokens": 1000, "temperature": 0.7 } }, "userAgent": "antigravity", "requestId": "agent-abc123" } ``` --- ## Response Headers | Header | Description | |--------|-------------| | `x-cloudaicompanion-trace-id` | Trace ID for debugging | | `server-timing` | Request duration | --- ## Comparison: Antigravity vs Vertex AI Anthropic | Feature | Antigravity | Vertex AI Anthropic | |---------|-------------|---------------------| | Endpoint | `cloudcode-pa.googleapis.com` | `aiplatform.googleapis.com` | | Request format | Gemini-style `contents` | Anthropic `messages` | | `anthropic_version` | Not used | Required | | Model names | Simple (`claude-sonnet-4-6`) | Versioned (`claude-4-5@date`) | | Response format | `candidates[]` | Anthropic `content[]` | | Multi-model support | Yes (Claude, Gemini, etc.) | Anthropic only | --- ## Changelog - **2025-12-14**: Added function calling quirks, JSON Schema support matrix, tool name rules - **2025-12-13**: Initial specification based on direct API testing ================================================ FILE: docs/ARCHITECTURE.md ================================================ # Architecture Guide **Last Updated:** December 2025 This document explains how the Antigravity plugin works, including the request/response flow, Claude-specific handling, and session recovery. --- ## Overview ``` ┌─────────────────────────────────────────────────────────────────┐ │ OpenCode ──▶ Plugin ──▶ Antigravity API ──▶ Claude/Gemini │ │ │ │ │ │ │ │ │ │ │ └─ Model │ │ │ │ └─ Google's gateway (Gemini fmt) │ │ │ └─ THIS PLUGIN (auth, transform, recovery) │ │ └─ AI coding assistant │ └─────────────────────────────────────────────────────────────────┘ ``` The plugin intercepts requests to `generativelanguage.googleapis.com`, transforms them for the Antigravity API, and handles authentication, rate limits, and error recovery. --- ## Module Structure ``` src/ ├── index.ts # Plugin exports ├── plugin.ts # Main entry, fetch interceptor ├── constants.ts # Endpoints, headers, config ├── antigravity/ │ └── oauth.ts # OAuth token exchange └── plugin/ ├── auth.ts # Token validation & refresh ├── request.ts # Request transformation (main logic) ├── request-helpers.ts # Schema cleaning, thinking filters ├── thinking-recovery.ts # Turn boundary detection, crash recovery ├── recovery.ts # Session recovery (tool_result_missing) ├── quota.ts # Quota checking (API usage stats) ├── cache.ts # Auth & signature caching ├── cache/ │ └── signature-cache.ts # Disk-based signature persistence ├── config/ │ ├── schema.ts # Zod config schema │ └── loader.ts # Config file loading ├── accounts.ts # Multi-account management ├── server.ts # OAuth callback server └── debug.ts # Debug logging ``` --- ## Request Flow ### 1. Interception (`plugin.ts`) ```typescript fetch() intercepted → isGenerativeLanguageRequest() → prepareAntigravityRequest() ``` - Account selection (round-robin, rate-limit aware) - Token refresh if expired - Endpoint fallback (daily → autopush → prod) ### 2. Request Transformation (`request.ts`) | Step | What Happens | |------|--------------| | Model detection | Detect Claude/Gemini from URL | | Thinking config | Add `thinkingConfig` for thinking models | | Thinking strip | Remove ALL thinking blocks (Claude) | | Tool normalization | Convert to `functionDeclarations[]` | | Schema cleaning | Remove unsupported JSON Schema fields | | ID assignment | Assign IDs to tool calls (FIFO matching) | | Wrap request | `{ project, model, request: {...} }` | ### 3. Response Transformation (`request.ts`) | Step | What Happens | |------|--------------| | SSE streaming | Real-time line-by-line TransformStream | | Signature caching | Cache `thoughtSignature` for display | | Format transform | `thought: true` → `type: "reasoning"` | | Envelope unwrap | Extract inner `response` object | --- ## Claude-Specific Handling ### Why Special Handling? Claude through Antigravity requires: 1. **Gemini format** - `contents[].parts[]` not `messages[].content[]` 2. **Thinking signatures** - Multi-turn needs signed blocks or errors 3. **Schema restrictions** - Rejects `const`, `$ref`, `$defs`, etc. 4. **Tool validation** - `VALIDATED` mode requires proper schemas ### Thinking Block Strategy (v2.0) **Problem:** OpenCode stores thinking blocks, but may corrupt signatures. **Solution:** Strip ALL thinking blocks from outgoing requests. ``` Turn 1 Response: { thought: true, text: "...", thoughtSignature: "abc" } ↓ (stored by OpenCode, possibly corrupted) Turn 2 Request: Plugin STRIPS all thinking blocks ↓ Claude API: Generates fresh thinking ``` **Why this works:** - Zero signature errors (impossible to have invalid signatures) - Same quality (Claude sees full conversation, re-thinks fresh) - Simpler code (no complex validation/restoration) ### Thinking Injection for Tool Use Claude API requires thinking before `tool_use` blocks. The plugin: 1. Caches signed thinking from responses (`lastSignedThinkingBySessionKey`) 2. On subsequent requests, injects cached thinking before tool_use 3. Only injects for the **first** assistant message of a turn (not every message) **Turn boundary detection** (`thinking-recovery.ts`): ```typescript // A "turn" starts after a real user message (not tool_result) // Only inject thinking into first assistant message after that ``` --- ## Session Recovery ### Tool Result Missing Error When a tool execution is interrupted (ESC, timeout, crash): ``` Error: tool_use ids were found without tool_result blocks immediately after ``` **Recovery flow** (`recovery.ts`): 1. Detect error via `session.error` event 2. Fetch session messages via `client.session.messages()` 3. Extract `tool_use` IDs from failed message 4. Inject synthetic `tool_result` blocks: ```typescript { type: "tool_result", tool_use_id: id, content: "Operation cancelled" } ``` 5. Send via `client.session.prompt()` 6. Optionally auto-resume with "continue" ### Thinking Block Order Error ``` Error: Expected thinking but found text ``` **Recovery** (`thinking-recovery.ts`): 1. Detect conversation is in tool loop without thinking at turn start 2. Close the corrupted turn with synthetic messages 3. Start fresh turn where Claude can generate new thinking --- ## Schema Cleaning Claude rejects unsupported JSON Schema features. The plugin uses an **allowlist approach**: **Kept:** `type`, `properties`, `required`, `description`, `enum`, `items` **Removed:** `const`, `$ref`, `$defs`, `default`, `examples`, `additionalProperties`, `$schema`, `title` **Transformations:** - `const: "value"` → `enum: ["value"]` - Empty object schema → Add placeholder `reason` property --- ## Multi-Account Load Balancing ### How It Works 1. **Sticky selection** - Same account until rate limited (preserves cache) 2. **Per-model-family** - Claude/Gemini rate limits tracked separately 3. **Dual quota (Gemini)** - Antigravity + Gemini CLI headers 4. **Automatic failover** - On 429, switch to next available account ### Account Storage Location: `~/.config/opencode/antigravity-accounts.json` Contains OAuth refresh tokens - treat as sensitive. --- ## Configuration ### Environment Variables | Variable | Purpose | |----------|---------| | `OPENCODE_ANTIGRAVITY_DEBUG` | `1` or `2` for file debug logging | | `OPENCODE_ANTIGRAVITY_DEBUG_TUI` | `1` or `true` for TUI log panel debug output | | `OPENCODE_ANTIGRAVITY_QUIET` | Suppress toast notifications | `debug` and `debug_tui` are independent sinks: `debug` controls file logs, while `debug_tui` controls TUI logs. ### Config File Location: `~/.config/opencode/antigravity.json` ```json { "session_recovery": true, "auto_resume": true, "resume_text": "continue", "keep_thinking": false } ``` --- ## Key Functions Reference ### `request.ts` | Function | Purpose | |----------|---------| | `prepareAntigravityRequest()` | Main request transformation | | `transformAntigravityResponse()` | SSE streaming, format conversion | | `ensureThinkingBeforeToolUseInContents()` | Inject cached thinking | | `createStreamingTransformer()` | Real-time SSE processing | ### `request-helpers.ts` | Function | Purpose | |----------|---------| | `deepFilterThinkingBlocks()` | Recursive thinking block removal | | `cleanJSONSchemaForAntigravity()` | Schema sanitization | | `transformThinkingParts()` | `thought` → `reasoning` format | ### `thinking-recovery.ts` | Function | Purpose | |----------|---------| | `analyzeConversationState()` | Detect turn boundaries, tool loops | | `needsThinkingRecovery()` | Check if recovery needed | | `closeToolLoopForThinking()` | Inject synthetic messages | ### `recovery.ts` | Function | Purpose | |----------|---------| | `handleSessionRecovery()` | Main recovery orchestration | | `createSessionRecoveryHook()` | Hook factory for plugin | --- ## Debugging ### Enable Logging ```bash export OPENCODE_ANTIGRAVITY_DEBUG=2 # Verbose file logs export OPENCODE_ANTIGRAVITY_DEBUG_TUI=1 # TUI log panel output ``` ### Log Location `~/.config/opencode/antigravity-logs/` ### What To Check 1. Is `isClaudeModel` true for Claude models? 2. Are thinking blocks being stripped? 3. Are tool schemas being cleaned? 4. Is session recovery triggering? --- ## Troubleshooting | Error | Cause | Solution | |-------|-------|----------| | `invalid signature` | Corrupted thinking block | Update plugin (strips all thinking) | | `Unknown field: const` | Schema uses `const` | Plugin auto-converts to `enum` | | `tool_use without tool_result` | Interrupted execution | Session recovery injects results | | `Expected thinking but found text` | Turn started without thinking | Thinking recovery closes turn | | `429 Too Many Requests` | Rate limited | Plugin auto-rotates accounts | --- ## See Also - [ANTIGRAVITY_API_SPEC.md](./ANTIGRAVITY_API_SPEC.md) - API reference - [README.md](../README.md) - Installation & usage ================================================ FILE: docs/CONFIGURATION.md ================================================ # Configuration Create `~/.config/opencode/antigravity.json` (or `.opencode/antigravity.json` in project root): ```json { "$schema": "https://raw.githubusercontent.com/NoeFabris/opencode-antigravity-auth/main/assets/antigravity.schema.json" } ``` Most settings have sensible defaults. Only configure what you need. --- ## Quick Start **Minimal config (recommended for most users):** ```json { "$schema": "https://raw.githubusercontent.com/NoeFabris/opencode-antigravity-auth/main/assets/antigravity.schema.json" } ``` **With web search enabled:** The plugin provides a `google_search` tool that the model can call to search the web. No configuration is needed - the tool is always available. --- ## Model Behavior Settings that affect how the model thinks and responds. | Option | Default | Description | |--------|---------|-------------| | `keep_thinking` | `false` | Preserve Claude's thinking blocks across turns. **Warning:** enabling may degrade model stability. | | `session_recovery` | `true` | Auto-recover from tool_result_missing errors | | `auto_resume` | `false` | Auto-send resume prompt after recovery | | `resume_text` | `"continue"` | Text to send when auto-resuming | > **Note:** The `web_search` config options are deprecated. Google Search is now implemented as a dedicated `google_search` tool that the model can call explicitly. ### About `keep_thinking` When `true`, Claude's thinking blocks are preserved in conversation history: - **Pros:** Model remembers its reasoning, more coherent across turns - **Cons:** May degrade model stability, slightly larger context When `false` (default), thinking is stripped: - **Pros:** More stable model behavior, smaller context - **Cons:** Model may be less coherent, forgets previous reasoning --- ## Account Rotation Settings for managing multiple Google accounts. | Option | Default | Description | |--------|---------|-------------| | `account_selection_strategy` | `"hybrid"` | How to select accounts | | `switch_on_first_rate_limit` | `true` | Switch account immediately on first 429 | | `pid_offset_enabled` | `false` | Distribute sessions across accounts (for parallel agents) | | `quota_fallback` | `false` | Deprecated (ignored). Kept for backward compatibility; Gemini fallback is automatic | ### Strategy Guide | Your Setup | Recommended Strategy | Why | |------------|---------------------|-----| | **1 account** | `"sticky"` | No rotation needed, preserve prompt cache | | **2-3 accounts** | `"hybrid"` (default) | Smart rotation with health scoring | | **4+ accounts** | `"round-robin"` | Maximum throughput | | **Parallel agents** | `"round-robin"` + `pid_offset_enabled: true` | Distribute across accounts | ### Available Strategies | Strategy | Behavior | Best For | |----------|----------|----------| | `sticky` | Same account until rate-limited | Single account, prompt cache | | `round-robin` | Rotate on every request | Maximum throughput | | `hybrid` | Health score + token bucket + LRU | Smart distribution (default) | --- ## App Behavior Settings for plugin behavior. | Option | Default | Description | |--------|---------|-------------| | `quiet_mode` | `false` | Hide toast notifications (except recovery) | | `debug` | `false` | Enable debug logging | | `log_dir` | OS default | Custom directory for debug logs | | `auto_update` | `true` | Enable automatic plugin updates | ### Debug Logging ```json { "debug": true, "debug_tui": true } ``` Logs are written to `~/.config/opencode/antigravity-logs/` (or `log_dir` if set). --- ## Recommended Configs Copy-paste ready configs with all recommended settings enabled. ### 1 Account ```json { "$schema": "https://raw.githubusercontent.com/NoeFabris/opencode-antigravity-auth/main/assets/antigravity.schema.json", "account_selection_strategy": "sticky" } ``` **Why these settings:** - `sticky` — No rotation needed, preserves Anthropic prompt cache ### 2-3 Accounts ```json { "$schema": "https://raw.githubusercontent.com/NoeFabris/opencode-antigravity-auth/main/assets/antigravity.schema.json", "account_selection_strategy": "hybrid" } ``` **Why these settings:** - `hybrid` — Smart rotation using health scores, avoids bad accounts ### 3+ Accounts (Power Users / Parallel Agents) ```json { "$schema": "https://raw.githubusercontent.com/NoeFabris/opencode-antigravity-auth/main/assets/antigravity.schema.json", "account_selection_strategy": "round-robin", "switch_on_first_rate_limit": true, "pid_offset_enabled": true } ``` **Why these settings:** - `round-robin` — Maximum throughput, rotates every request - `switch_on_first_rate_limit` — Immediately switch on 429 (default: true) - `pid_offset_enabled` — Different sessions use different starting accounts --- ## What's Enabled by Default These settings are already `true` by default — you don't need to set them: | Setting | Default | What it does | |---------|---------|--------------| | `session_recovery` | `true` | Auto-recover from errors | | `auto_update` | `true` | Keep plugin updated | | `switch_on_first_rate_limit` | `true` | Fast account switching | These settings are `false` by default: | Setting | Default | What it does | |---------|---------|--------------| | `keep_thinking` | `false` | Preserve Claude thinking (may degrade stability) | | `auto_resume` | `false` | Auto-continue after recovery | --- ## Advanced Settings > These settings are for edge cases. Most users don't need to change them.
Error Recovery (internal) | Option | Default | Description | |--------|---------|-------------| | `empty_response_max_attempts` | `4` | Retries for empty API responses | | `empty_response_retry_delay_ms` | `2000` | Delay between retries | | `tool_id_recovery` | `true` | Fix mismatched tool IDs from context compaction | | `claude_tool_hardening` | `true` | Prevent tool parameter hallucination | | `max_rate_limit_wait_seconds` | `300` | Max wait time when rate limited (0=unlimited) |
Token Management (internal) | Option | Default | Description | |--------|---------|-------------| | `proactive_token_refresh` | `true` | Refresh tokens before expiry | | `proactive_refresh_buffer_seconds` | `1800` | Refresh 30 min before expiry | | `proactive_refresh_check_interval_seconds` | `300` | Check interval |
Signature Cache (internal) Used when `keep_thinking: true`. Most users don't need to configure this. | Option | Default | Description | |--------|---------|-------------| | `signature_cache.enabled` | `true` | Enable disk caching | | `signature_cache.memory_ttl_seconds` | `3600` | In-memory cache TTL (1 hour) | | `signature_cache.disk_ttl_seconds` | `172800` | Disk cache TTL (48 hours) | | `signature_cache.write_interval_seconds` | `60` | Background write interval |
Health Score Tuning (internal) Used by `hybrid` strategy. Most users don't need to configure this. | Option | Default | Description | |--------|---------|-------------| | `health_score.initial` | `70` | Starting health score | | `health_score.success_reward` | `1` | Points added on success | | `health_score.rate_limit_penalty` | `-10` | Points removed on rate limit | | `health_score.failure_penalty` | `-20` | Points removed on failure | | `health_score.recovery_rate_per_hour` | `2` | Points recovered per hour | | `health_score.min_usable` | `50` | Minimum score to use account | | `health_score.max_score` | `100` | Maximum health score |
Token Bucket Tuning (internal) Used by `hybrid` strategy. Most users don't need to configure this. | Option | Default | Description | |--------|---------|-------------| | `token_bucket.max_tokens` | `50` | Maximum tokens in bucket | | `token_bucket.regeneration_rate_per_minute` | `6` | Tokens regenerated per minute | | `token_bucket.initial_tokens` | `50` | Starting tokens |
================================================ FILE: docs/MODEL-VARIANTS.md ================================================ # Model Variants OpenCode's variant system allows you to configure thinking budget dynamically instead of defining separate models for each thinking level. --- ## How Variants Work When you define a model with `variants`, OpenCode shows variant options in the model picker. Selecting a variant passes the `providerOptions` to the plugin, which extracts the thinking configuration. ```bash opencode run "Hello" --model=google/antigravity-claude-opus-4-6-thinking --variant=max ``` --- ## Variant Configuration Define variants in your model configuration: ```json { "antigravity-claude-opus-4-6-thinking": { "name": "Claude Opus 4.6 Thinking", "limit": { "context": 200000, "output": 64000 }, "modalities": { "input": ["text", "image", "pdf"], "output": ["text"] }, "variants": { "low": { "thinkingConfig": { "thinkingBudget": 8192 } }, "max": { "thinkingConfig": { "thinkingBudget": 32768 } } } } } ``` --- ## Supported Variant Formats The plugin accepts different variant formats depending on the model family: | Model Family | Variant Format | Example | |--------------|----------------|---------| | **Claude** | `thinkingConfig.thinkingBudget` | `{ "thinkingConfig": { "thinkingBudget": 8192 } }` | | **Gemini 3** | `thinkingLevel` | `{ "thinkingLevel": "high" }` | | **Gemini 2.5** | `thinkingConfig.thinkingBudget` | `{ "thinkingConfig": { "thinkingBudget": 8192 } }` | --- ## Gemini 3 Thinking Levels Gemini 3 models use string-based thinking levels. Available levels differ by model: | Level | Flash | Pro | Description | |-------|-------|-----|-------------| | `minimal` | ✅ | ❌ | Minimal thinking, lowest latency | | `low` | ✅ | ✅ | Light thinking | | `medium` | ✅ | ❌ | Balanced thinking | | `high` | ✅ | ✅ | Maximum thinking (default) | > **Note:** The API rejects invalid levels (e.g., `"minimal"` on Pro). Configure variants accordingly. ### Gemini 3 Pro Example ```json { "antigravity-gemini-3-pro": { "name": "Gemini 3 Pro (Antigravity)", "limit": { "context": 1048576, "output": 65535 }, "modalities": { "input": ["text", "image", "pdf"], "output": ["text"] }, "variants": { "low": { "thinkingLevel": "low" }, "high": { "thinkingLevel": "high" } } } } ``` ### Gemini 3 Flash Example ```json { "antigravity-gemini-3-flash": { "name": "Gemini 3 Flash (Antigravity)", "limit": { "context": 1048576, "output": 65536 }, "modalities": { "input": ["text", "image", "pdf"], "output": ["text"] }, "variants": { "minimal": { "thinkingLevel": "minimal" }, "low": { "thinkingLevel": "low" }, "medium": { "thinkingLevel": "medium" }, "high": { "thinkingLevel": "high" } } } } ``` --- ## Claude Thinking Budget Claude models use token-based thinking budgets: | Variant | Budget | Description | |---------|--------|-------------| | `low` | 8192 | Light thinking | | `max` | 32768 | Maximum thinking | ### Claude Example ```json { "antigravity-claude-opus-4-6-thinking": { "name": "Claude Opus 4.6 Thinking (Antigravity)", "limit": { "context": 200000, "output": 64000 }, "modalities": { "input": ["text", "image", "pdf"], "output": ["text"] }, "variants": { "low": { "thinkingConfig": { "thinkingBudget": 8192 } }, "max": { "thinkingConfig": { "thinkingBudget": 32768 } } } } } ``` You can define custom budgets: ```json { "variants": { "minimal": { "thinkingConfig": { "thinkingBudget": 4096 } }, "low": { "thinkingConfig": { "thinkingBudget": 8192 } }, "medium": { "thinkingConfig": { "thinkingBudget": 16384 } }, "high": { "thinkingConfig": { "thinkingBudget": 24576 } }, "max": { "thinkingConfig": { "thinkingBudget": 32768 } } } } ``` --- ## Legacy Budget Format (Deprecated) For Gemini 3 models, the old `thinkingBudget` format is still supported but deprecated: | Budget Range | Maps to Level | |--------------|---------------| | ≤ 8192 | low | | ≤ 16384 | medium | | > 16384 | high | **Recommended:** Use `thinkingLevel` directly for Gemini 3 models. --- ## Tier-Suffixed Names Tier-suffixed model names are still accepted: - `antigravity-claude-opus-4-6-thinking-low` - `antigravity-claude-opus-4-6-thinking-medium` - `antigravity-claude-opus-4-6-thinking-high` - `antigravity-gemini-3-pro-low` - `antigravity-gemini-3-pro-high` - `gemini-3-pro-low` - `gemini-3-flash-medium` However, **we recommend using simplified model names with variants** for: - **Cleaner model picker** — 7 models instead of 12+ - **Simpler config** — No need to configure both `antigravity-` and `-preview` versions - **Automatic quota routing** — Plugin handles model name transformation - **Flexible budgets** — Define any budget, not just preset tiers - **Future-proof** — Works with OpenCode's native variant system --- ## Benefits of Variants | Before (tier-suffixed) | After (variants) | |------------------------|------------------| | 12+ separate models | 4 models with variants | | Fixed thinking budgets | Customizable budgets | | Cluttered model picker | Clean model picker | | Hard to add new tiers | Easy to add new variants | ================================================ FILE: docs/MULTI-ACCOUNT.md ================================================ # Multi-Account Setup Add multiple Google accounts to increase your combined quota. The plugin automatically rotates between accounts when one is rate-limited. ```bash opencode auth login # Run again to add more accounts ``` --- ## Load Balancing Behavior - **Sticky account selection** — Sticks to the same account until rate-limited (preserves Anthropic's prompt cache) - **Per-model-family limits** — Rate limits tracked separately for Claude and Gemini models - **Antigravity-first for Gemini** — All Gemini requests use Antigravity quota first, then automatically fall back to Gemini CLI when exhausted across all accounts - **Smart retry threshold** — Short rate limits (≤5s) are retried on same account - **Exponential backoff** — Increasing delays for consecutive rate limits --- ## Dual Quota Pools For Gemini models, the plugin accesses **two independent quota pools** per account: | Quota Pool | When Used | |------------|-----------| | **Antigravity** | Default for all requests | | **Gemini CLI** | Automatic fallback between Antigravity and Gemini CLI in both directions | This effectively **doubles your Gemini quota** through automatic fallback between Antigravity and Gemini CLI pools. ### How Quota Fallback Works 1. Request uses Antigravity quota on current account 2. If rate-limited, plugin checks if ANY other account has Antigravity available 3. If yes → switch to that account (stay on Antigravity) 4. If no (all accounts exhausted) → fall back to Gemini CLI quota on current account 5. Model names are automatically transformed (e.g., `gemini-3-flash` → `gemini-3-flash-preview`) Automatic fallback between pools is always enabled for Gemini requests. --- ## Checking Quotas Check your current API usage across all accounts: ```bash opencode auth login # Select "Check quotas" from the menu ``` This shows remaining quota percentages and reset times for each model family: - **Claude** - Claude Opus/Sonnet quota - **Gemini 3 Pro** - Gemini 3 Pro quota - **Gemini 3 Flash** - Gemini 3 Flash quota ### Standalone Quota Script For checking quotas outside OpenCode (debugging, CI, etc.): ```bash node scripts/check-quota.mjs # Check all accounts node scripts/check-quota.mjs --account 2 # Check specific account node scripts/check-quota.mjs --path /path/to/accounts.json # Custom path ``` --- ## Managing Accounts Enable or disable specific accounts to control which ones are used for requests: ```bash opencode auth login # Select "Manage accounts (enable/disable)" ``` Or select an account from the list and choose "Enable/Disable account". **Disabled accounts:** - Are excluded from automatic rotation - Still appear in quota checks (marked `[disabled]`) - Can be re-enabled at any time This is useful when: - An account is temporarily banned or rate-limited for extended periods - You want to reserve certain accounts for specific use cases - Testing with a subset of accounts --- ## Adding Accounts When running `opencode auth login` with existing accounts: ``` 2 account(s) saved: 1. user1@gmail.com 2. user2@gmail.com (a)dd new account(s) or (f)resh start? [a/f]: ``` Choose `a` to add more accounts while keeping existing ones. --- ## Account Storage Accounts are stored in `~/.config/opencode/antigravity-accounts.json`: ```json { "version": 3, "accounts": [ { "email": "user1@gmail.com", "refreshToken": "1//0abc...", "projectId": "my-gcp-project", "enabled": true }, { "email": "user2@gmail.com", "refreshToken": "1//0xyz...", "enabled": false } ], "activeIndex": 0, "activeIndexByFamily": { "claude": 0, "gemini": 0 } } ``` > ⚠️ **Security:** This file contains OAuth refresh tokens. Treat it like a password file. ### Fields | Field | Description | |-------|-------------| | `email` | Google account email | | `refreshToken` | OAuth refresh token (auto-managed) | | `projectId` | Optional. Required for Gemini CLI models. See [Troubleshooting](TROUBLESHOOTING.md#gemini-cli-permission-error). | | `enabled` | Optional. Set to `false` to disable account rotation. Defaults to `true`. | | `activeIndex` | Currently active account index | | `activeIndexByFamily` | Per-model-family active account (claude/gemini tracked separately) | --- ## Token Revocation If Google revokes a token (e.g., password change, security event), you'll see `invalid_grant` errors. The plugin automatically removes invalid accounts. To manually reset: ```bash rm ~/.config/opencode/antigravity-accounts.json opencode auth login ``` --- ## Parallel Sessions (oh-my-opencode) When using oh-my-opencode with parallel subagents, multiple processes may select the same account, causing rate limit errors. **Solution:** Enable PID-based offset in `antigravity.json`: ```json { "pid_offset_enabled": true } ``` This distributes sessions across accounts based on process ID. Alternatively, add more accounts via `opencode auth login`. --- ## Account Selection Strategies Configure in `antigravity.json`: ```json { "account_selection_strategy": "hybrid" } ``` | Strategy | Behavior | Best For | |----------|----------|----------| | `sticky` | Same account until rate-limited | Prompt cache preservation | | `round-robin` | Rotate to next account on every request | Maximum throughput | | `hybrid` | Deterministic selection based on health score + token bucket + LRU | Best overall distribution | See [Configuration](CONFIGURATION.md#account-selection) for more details. ================================================ FILE: docs/TROUBLESHOOTING.md ================================================ # Troubleshooting Common issues and solutions for the Antigravity Auth plugin. > **Quick Reset**: Most issues can be resolved by deleting `~/.config/opencode/antigravity-accounts.json` and running `opencode auth login` again. --- ## Configuration Paths (All Platforms) OpenCode uses `~/.config/opencode/` on **all platforms** including Windows. | File | Path | |------|------| | Main config | `~/.config/opencode/opencode.json` | | Accounts | `~/.config/opencode/antigravity-accounts.json` | | Plugin config | `~/.config/opencode/antigravity.json` | | Debug logs | `~/.config/opencode/antigravity-logs/` | > **Windows users**: `~` resolves to your user home directory (e.g., `C:\Users\YourName`). Do NOT use `%APPDATA%`. --- ## Quick Fixes ### Auth problems Delete the token file and re-login: ```bash rm ~/.config/opencode/antigravity-accounts.json opencode auth login ``` ### "This version of Antigravity is no longer supported" This almost always means an outdated Antigravity `User-Agent` is still being used. 1) Stop any running OpenCode processes (stale processes can overwrite your accounts file): **macOS/Linux:** ```bash pkill -f opencode || true ``` **Windows (PowerShell):** ```powershell Stop-Process -Name "opencode" -Force -ErrorAction SilentlyContinue ``` 2) Clear the plugin caches and re-login: **macOS/Linux:** ```bash rm -f ~/.config/opencode/antigravity-accounts.json rm -rf ~/.cache/opencode/node_modules/opencode-antigravity-auth rm -rf ~/.bun/install/cache/opencode-antigravity-auth* opencode auth login ``` **Windows (PowerShell):** ```powershell Remove-Item "$env:APPDATA\opencode\antigravity-accounts.json" -Force -ErrorAction SilentlyContinue Remove-Item "$env:LOCALAPPDATA\opencode\Cache\node_modules\opencode-antigravity-auth" -Recurse -Force -ErrorAction SilentlyContinue Remove-Item "$env:USERPROFILE\.bun\install\cache\opencode-antigravity-auth*" -Recurse -Force -ErrorAction SilentlyContinue opencode auth login ``` ### "Model not found" Add this to your `google` provider config: ```json "npm": "@ai-sdk/google" ``` ### Session errors Type `continue` to trigger auto-recovery, or use `/undo` to rollback. ### Configuration Key Typo The correct key is `plugin` (singular): ```json { "plugin": ["opencode-antigravity-auth@latest"] } ``` **Not** `"plugins"` (will cause "Unrecognized key" error). ### "Invalid SemVer: beta" **Error:** ``` Invalid SemVer { "name": "UnknownError", "data": { "message": "Error: Invalid SemVer: beta ... isOutdated (src/bun/registry.ts:...)" } } ``` **Why this happens:** OpenCode's cache may keep the plugin dependency as a dist-tag (`"beta"`) in `~/.cache/opencode/package.json` and `~/.cache/opencode/bun.lock`. Some OpenCode versions compare plugin versions as strict semver and fail on non-numeric tags. **Fix (recommended):** Re-resolve the dependency in OpenCode cache so it is pinned to a real version. **macOS / Linux:** ```bash cd ~/.cache/opencode bun add opencode-antigravity-auth@latest ``` **Windows (PowerShell):** ```powershell Set-Location "$env:USERPROFILE\.cache\opencode" bun add opencode-antigravity-auth@latest ``` Then restart OpenCode. > If you intentionally run beta channel, use `bun add opencode-antigravity-auth@beta` instead. --- ## Gemini CLI Permission Error When using Gemini CLI models, you may see: > Permission 'cloudaicompanion.companions.generateChat' denied on resource '//cloudaicompanion.googleapis.com/projects/...' **Why this happens:** The plugin defaults to a predefined project ID that doesn't exist in your Google Cloud account. Antigravity models work, but Gemini CLI models need your own project. **Solution:** 1. Go to [Google Cloud Console](https://console.cloud.google.com/) 2. Create or select a project 3. Enable the **Gemini for Google Cloud API** (`cloudaicompanion.googleapis.com`) 4. Add `projectId` to your account in `~/.config/opencode/antigravity-accounts.json`: ```json { "version": 3, "accounts": [ { "email": "you@gmail.com", "refreshToken": "...", "projectId": "your-project-id" } ] } ``` > **Note:** For multi-account setups, add `projectId` to each account. --- ## Gemini 3 Models 400 Error ("Unknown name 'parameters'") **Error:** ``` Invalid JSON payload received. Unknown name "parameters" at 'request.tools[0]' ``` **Causes:** - Tool schema incompatibility with Gemini's strict protobuf validation - MCP servers with malformed schemas - Plugin version regression **Solutions:** 1. **Update to latest beta:** ```json { "plugin": ["opencode-antigravity-auth@beta"] } ``` 2. **Disable MCP servers** one-by-one to find the problematic one 3. **Add npm override:** ```json { "provider": { "google": { "npm": "@ai-sdk/google" } } } ``` --- ## MCP Servers Causing Errors Some MCP servers have schemas incompatible with Antigravity's strict JSON format. **Diagnosis:** 1. Disable all MCP servers in your config 2. Enable one-by-one until error reappears 3. Report the specific MCP in a [GitHub issue](https://github.com/NoeFabris/opencode-antigravity-auth/issues) --- ## Rate Limits, Shadow Bans, and Hanging Prompts **Symptoms:** - Prompts hang indefinitely (200 OK in logs but no response) - 403 "Permission Denied" errors even with fresh accounts - "All accounts rate-limited" but quota looks available - New accounts get rate-limited immediately after adding **Why this happens:** Google has significantly tightened quota and rate-limit enforcement. This affects ALL users, not just this plugin. Key factors: 1. **Stricter enforcement** — Even when quota "looks available," Google may throttle or soft-ban accounts that trigger their abuse detection 2. **OpenCode's request pattern** — OpenCode makes more API calls than native apps (tool calls, retries, streaming, multi-turn chains), which triggers limits faster than "normal" usage 3. **Shadow bans** — Some accounts become effectively unusable for extended periods once flagged, while others continue working normally > ⚠️ **Important:** Using this plugin may increase the chance of triggering automated abuse/rate-limit protections. The upstream provider can restrict, suspend, or terminate access at their discretion. **USE AT YOUR OWN RISK.** **Solutions:**
1. Wait it out (most reliable) Rate limits typically reset after a few hours. If you're seeing persistent issues: - Stop using the affected account for 24-48 hours - Use a different account in the meantime - Check `rateLimitResetTimes` in your accounts file to see when limits expire
2. "Warm up" accounts in Antigravity IDE (community tip) Users have reported success with this approach: 1. Open [Antigravity IDE](https://idx.google.com/) directly in your browser 2. Log in with the affected Google account 3. Run a few simple prompts (e.g., "Hello", "What's 2+2?") 4. After 5-10 successful prompts, try using the account with the plugin again **Why this might work:** Using the account through the "official" interface may reset some internal flags or make the account appear less suspicious.
3. Reduce request volume and burstiness - Use shorter sessions - Avoid parallel/retry-heavy workflows (e.g., spawning many subagents at once) - If using oh-my-opencode, consider reducing concurrent agent spawns - Set `max_rate_limit_wait_seconds: 0` to fail fast instead of retrying
4. Use Antigravity IDE directly (single account users) If you only have one account, you'll likely have a better experience using [Antigravity IDE](https://idx.google.com/) directly instead of routing through OpenCode, since OpenCode's request pattern triggers limits faster.
5. Fresh account setup If adding new accounts: 1. Delete accounts file: `rm ~/.config/opencode/antigravity-accounts.json` 2. Re-authenticate: `opencode auth login` 3. Update to latest beta: `"plugin": ["opencode-antigravity-auth@beta"]` 4. Consider "warming up" the account in Antigravity IDE first
**What to report:** If you're seeing unusual rate limit behavior, please share in a [GitHub issue](https://github.com/NoeFabris/opencode-antigravity-auth/issues): - Status codes from debug logs (403, 429, etc.) - How long the rate-limit state persists - Number of accounts and selection strategy used --- ## Infinite `.tmp` Files Created **Cause:** When account is rate-limited and plugin retries infinitely, it creates many temp files. **Workaround:** 1. Stop OpenCode 2. Clean up: `rm ~/.config/opencode/*.tmp` 3. Add more accounts or wait for rate limit to expire --- ## Safari OAuth Callback Fails (macOS) **Symptoms:** - "fail to authorize" after successful Google login - Safari shows "Safari can't open the page" or connection refused **Cause:** Safari's "HTTPS-Only Mode" blocks the `http://localhost` callback URL. **Solutions:** 1. **Use a different browser** (easiest): Copy the URL from `opencode auth login` and paste it into Chrome or Firefox. 2. **Temporarily disable HTTPS-Only Mode:** - Safari > Settings (⌘,) > Privacy - Uncheck "Enable HTTPS-Only Mode" - Run `opencode auth login` - Re-enable after authentication 3. **Manual callback extraction** (advanced): - When Safari shows the error, the address bar contains `?code=...&scope=...` - See [issue #119](https://github.com/NoeFabris/opencode-antigravity-auth/issues/119) for manual auth support --- ## Port Already in Use If OAuth fails with "Address already in use": **macOS / Linux:** ```bash lsof -i :51121 kill -9 opencode auth login ``` **Windows:** ```powershell netstat -ano | findstr :51121 taskkill /PID /F opencode auth login ``` --- ## WSL2 / Docker / Remote Development The OAuth callback requires the browser to reach `localhost` on the machine running OpenCode.
WSL2 - Use VS Code's port forwarding, or - Configure Windows → WSL port forwarding
SSH / Remote ```bash ssh -L 51121:localhost:51121 user@remote ```
Docker / Containers - OAuth with localhost redirect doesn't work in containers - Wait 30s for manual URL flow, or use SSH port forwarding
--- ## Migrating Accounts Between Machines When copying `antigravity-accounts.json` to a new machine: 1. Ensure the plugin is installed: `"plugin": ["opencode-antigravity-auth@beta"]` 2. Copy `~/.config/opencode/antigravity-accounts.json` 3. If you get "API key missing" error, the refresh token may be invalid — re-authenticate --- ## Plugin Compatibility Issues ### @tarquinen/opencode-dcp DCP creates synthetic assistant messages that lack thinking blocks. **List this plugin BEFORE DCP:** ```json { "plugin": [ "opencode-antigravity-auth@latest", "@tarquinen/opencode-dcp@latest" ] } ``` ### oh-my-opencode Disable built-in auth: ```json { "google_auth": false } ``` When spawning parallel subagents, multiple processes may hit the same account. **Workaround:** Enable `pid_offset_enabled: true` or add more accounts. ### Other gemini-auth plugins You don't need them. This plugin handles all Google OAuth. --- ## Migration Guides ### v1.2.8+ (Variants) v1.2.8+ introduces **model variants** for dynamic thinking configuration. **Before (v1.2.7):** ```json { "antigravity-claude-opus-4-6-thinking-low": { ... }, "antigravity-claude-opus-4-6-thinking-max": { ... } } ``` **After (v1.2.8+):** ```json { "antigravity-claude-opus-4-6-thinking": { "variants": { "low": { "thinkingConfig": { "thinkingBudget": 8192 } }, "max": { "thinkingConfig": { "thinkingBudget": 32768 } } } } } ``` Use canonical model names from current docs. Deprecated model names are sent as requested and may fail if the upstream API has removed them. ### v1.2.7 (Prefix) v1.2.7+ uses explicit `antigravity-` prefix: | Old Name | New Name | |----------|----------| | `gemini-3-pro-low` | `antigravity-gemini-3-pro` | | `claude-sonnet-4-6` | `antigravity-claude-sonnet-4-6` | Use the `antigravity-` prefixed model names shown above. --- ## Debugging Enable debug logging: ```json { "debug": true, "debug_tui": true } ``` Logs are in `~/.config/opencode/antigravity-logs/`. --- ## E2E Testing The plugin includes regression tests (consume API quota): ```bash npx tsx script/test-regression.ts --sanity # 7 tests, ~5 min npx tsx script/test-regression.ts --heavy # 4 tests, ~30 min npx tsx script/test-regression.ts --dry-run # List tests ``` --- ## Still stuck? Open an issue on [GitHub](https://github.com/NoeFabris/opencode-antigravity-auth/issues). ================================================ FILE: index.ts ================================================ export { AntigravityCLIOAuthPlugin, GoogleOAuthPlugin, } from "./src/plugin"; export { authorizeAntigravity, exchangeAntigravity, } from "./src/antigravity/oauth"; export type { AntigravityAuthorization, AntigravityTokenExchangeResult, } from "./src/antigravity/oauth"; ================================================ FILE: package.json ================================================ { "name": "opencode-antigravity-auth", "version": "1.6.0", "description": "Google Antigravity IDE OAuth auth plugin for Opencode - access Gemini 3 Pro and Claude 4.6 using Google credentials", "main": "./dist/index.js", "types": "./dist/index.d.ts", "type": "module", "license": "MIT", "author": "noefabris", "repository": { "type": "git", "url": "git+https://github.com/NoeFabris/opencode-antigravity-auth.git" }, "homepage": "https://github.com/NoeFabris/opencode-antigravity-auth#readme", "bugs": { "url": "https://github.com/NoeFabris/opencode-antigravity-auth/issues" }, "keywords": [ "opencode", "google", "antigravity", "gemini", "oauth", "plugin", "auth", "claude" ], "engines": { "node": ">=20.0.0" }, "files": [ "dist/", "README.md", "LICENSE" ], "scripts": { "build": "tsc -p tsconfig.build.json", "build:schema": "npx tsx script/build-schema.ts", "typecheck": "tsc --noEmit", "test": "vitest run", "test:watch": "vitest", "test:ui": "vitest --ui", "test:coverage": "vitest run --coverage", "prepublishOnly": "npm run build", "test:e2e:models": "npx tsx script/test-models.ts", "test:e2e:regression": "npx tsx script/test-regression.ts" }, "peerDependencies": { "typescript": "^5" }, "devDependencies": { "@types/node": "^24.10.1", "@types/proper-lockfile": "^4.1.4", "@vitest/coverage-v8": "^3.0.0", "@vitest/ui": "^3.0.0", "typescript": "^5.0.0", "vitest": "^3.0.0", "zod-to-json-schema": "^3.25.1" }, "dependencies": { "@opencode-ai/plugin": "^0.15.30", "@openauthjs/openauth": "^0.4.3", "proper-lockfile": "^4.1.2", "xdg-basedir": "^5.1.0", "zod": "^4.0.0" } } ================================================ FILE: script/build-schema.ts ================================================ import { writeFileSync, mkdirSync } from "node:fs"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { AntigravityConfigSchema } from "../src/plugin/config/schema.js"; const __dirname = dirname(fileURLToPath(import.meta.url)); const outputPath = join(__dirname, "../assets/antigravity.schema.json"); // Use zod v4's built-in toJSONSchema method const rawSchema = AntigravityConfigSchema.toJSONSchema({ unrepresentable: "any", override: (_ctx) => undefined // Use default handling }) as Record; // Remove the "required" array since all fields have defaults and are optional // This preserves backwards compatibility with the draft-07 schema behavior delete rawSchema.required; const optionDescriptions: Record = { quiet_mode: "Suppress most toast notifications (rate limit, account switching). Recovery toasts always shown.", debug: "Enable debug logging to file.", log_dir: "Custom directory for debug logs.", keep_thinking: "Preserve thinking blocks for Claude models using signature caching. May cause signature errors.", session_recovery: "Enable automatic session recovery from tool_result_missing errors.", auto_resume: "Automatically send resume prompt after successful recovery.", resume_text: "Custom text to send when auto-resuming after recovery.", empty_response_max_attempts: "Maximum retry attempts when Antigravity returns an empty response (no candidates).", empty_response_retry_delay_ms: "Delay in milliseconds between empty response retries.", tool_id_recovery: "Enable tool ID orphan recovery. Matches mismatched tool responses by function name or creates placeholders.", claude_tool_hardening: "Enable tool hallucination prevention for Claude models. Injects parameter signatures and strict usage rules.", claude_prompt_auto_caching: "Enable Claude prompt auto-caching by adding top-level cache_control when absent.", proactive_token_refresh: "Enable proactive background token refresh before expiry, ensuring requests never block.", proactive_refresh_buffer_seconds: "Seconds before token expiry to trigger proactive refresh.", proactive_refresh_check_interval_seconds: "Interval between proactive refresh checks in seconds.", auto_update: "Enable automatic plugin updates.", quota_fallback: "Deprecated: accepted for backward compatibility but ignored at runtime. Gemini fallback between Antigravity and Gemini CLI is always enabled.", cli_first: "Prefer gemini-cli routing before Antigravity for Gemini models. When false (default), Antigravity is tried first and gemini-cli is fallback.", toast_scope: "Controls which sessions show toast notifications. 'root_only' (default) shows in root session only, 'all' shows in all sessions.", scheduling_mode: "Rate limit scheduling strategy. 'cache_first' (default) waits for cooldowns, 'balance' distributes across accounts, 'performance_first' picks fastest available.", max_cache_first_wait_seconds: "Maximum seconds to wait for a rate-limited account in cache_first mode before switching.", failure_ttl_seconds: "Time in seconds before a failed account is eligible for retry.", request_jitter_max_ms: "Maximum random jitter in milliseconds added to outgoing requests to avoid thundering herd.", soft_quota_threshold_percent: "Percentage of quota usage that triggers soft quota warnings and preemptive account switching.", quota_refresh_interval_minutes: "Interval in minutes between quota usage checks. Set to 0 to disable periodic checks.", soft_quota_cache_ttl_minutes: "TTL for cached soft quota data. 'auto' (default) calculates from refresh interval, or set a fixed number of minutes.", }; const signatureCacheDescriptions: Record = { enabled: "Enable disk caching of thinking block signatures.", memory_ttl_seconds: "In-memory TTL in seconds.", disk_ttl_seconds: "Disk TTL in seconds.", write_interval_seconds: "Background write interval in seconds.", }; function addDescriptions(schema: Record): void { const props = schema.properties as Record> | undefined; if (!props) return; for (const [key, prop] of Object.entries(props)) { if (optionDescriptions[key]) { prop.description = optionDescriptions[key]; } if (key === "signature_cache" && prop.properties) { const cacheProps = prop.properties as Record>; for (const [cacheKey, cacheProp] of Object.entries(cacheProps)) { if (signatureCacheDescriptions[cacheKey]) { cacheProp.description = signatureCacheDescriptions[cacheKey]; } } prop.description = "Signature cache configuration for persisting thinking block signatures. Only used when keep_thinking is enabled."; } } } const definitions = rawSchema.definitions as Record> | undefined; if (definitions?.AntigravityConfig) { addDescriptions(definitions.AntigravityConfig); } else { addDescriptions(rawSchema); } mkdirSync(dirname(outputPath), { recursive: true }); writeFileSync(outputPath, JSON.stringify(rawSchema, null, 2) + "\n"); console.log(`Schema written to ${outputPath}`); ================================================ FILE: script/test-cross-model-e2e.sh ================================================ #!/bin/bash # Cross-Model E2E Test Suite - 5 Model Variants # Tests fix for "Invalid `signature` in `thinking` block" error # # Models tested: # 1. Gemini (google/antigravity-gemini-3-pro-low, gemini-3-flash) # 2. Claude via Anthropic (anthropic/claude-opus-4-5) # 3. Claude via Google (google/antigravity-claude-*-thinking-*) # 4. OpenAI (openai/gpt-5.2-medium) set -euo pipefail PASS=0 FAIL=0 SKIP=0 GREEN='\033[0;32m' RED='\033[0;31m' YELLOW='\033[0;33m' BLUE='\033[0;34m' NC='\033[0m' log_pass() { echo -e "${GREEN}✓ PASS${NC}: $1"; ((PASS++)); } log_fail() { echo -e "${RED}✗ FAIL${NC}: $1"; ((FAIL++)); } log_skip() { echo -e "${YELLOW}○ SKIP${NC}: $1"; ((SKIP++)); } log_info() { echo -e " ${BLUE}→${NC} $1"; } get_session_id() { sleep 1 opencode session list 2>/dev/null | grep -oP 'ses_[a-zA-Z0-9]+' | head -1 || true } check_signature_error() { grep -qi "Invalid.*signature" "$1" 2>/dev/null && return 0 || return 1 } echo "════════════════════════════════════════════════════════════" echo " Cross-Model E2E Test Suite - 5 Model Variants" echo "════════════════════════════════════════════════════════════" echo "" # Test 1: Gemini → Anthropic Claude (original bug + direct Anthropic API) echo "Test 1: Gemini Pro → Anthropic Claude Opus (direct API)" log_info "Step 1: Gemini with thinking + tool..." opencode run -m google/antigravity-gemini-3-pro-low \ "Run: echo 'Test1-Gemini'. Think about sequences." \ > /tmp/e2e-t1-s1.log 2>&1 || true SID=$(get_session_id) if [ -z "$SID" ]; then log_fail "Test 1 - No session ID" else log_info "Session: $SID" log_info "Step 2: Anthropic Claude Opus + tool..." opencode run -s "$SID" -m anthropic/claude-opus-4-5 \ "Run: echo 'Test1-Anthropic-Claude'" \ > /tmp/e2e-t1-s2.log 2>&1 || true if check_signature_error /tmp/e2e-t1-s2.log; then log_fail "Test 1 - Invalid signature error (Gemini → Anthropic Claude)" else log_pass "Test 1 - Gemini → Anthropic Claude" fi fi echo "" # Test 2: Gemini → Google Claude (Google-hosted Claude) echo "Test 2: Gemini Pro → Google Claude Opus Thinking" log_info "Step 1: Gemini with thinking + tool..." opencode run -m google/antigravity-gemini-3-pro-low \ "Run: echo 'Test2-Gemini'. Think about this." \ > /tmp/e2e-t2-s1.log 2>&1 || true SID=$(get_session_id) if [ -z "$SID" ]; then log_fail "Test 2 - No session ID" else log_info "Session: $SID" log_info "Step 2: Google Claude Opus Thinking + tool..." opencode run -s "$SID" -m google/antigravity-claude-opus-4-6-thinking-low \ "Run: echo 'Test2-Google-Claude'" \ > /tmp/e2e-t2-s2.log 2>&1 || true if check_signature_error /tmp/e2e-t2-s2.log; then log_fail "Test 2 - Invalid signature error (Gemini → Google Claude)" else log_pass "Test 2 - Gemini → Google Claude Thinking" fi fi echo "" # Test 3: Gemini → OpenAI echo "Test 3: Gemini Pro → OpenAI GPT-5.2" log_info "Step 1: Gemini with thinking + tool..." opencode run -m google/antigravity-gemini-3-pro-low \ "Run: echo 'Test3-Gemini'. Think about AI models." \ > /tmp/e2e-t3-s1.log 2>&1 || true SID=$(get_session_id) if [ -z "$SID" ]; then log_fail "Test 3 - No session ID" else log_info "Session: $SID" log_info "Step 2: OpenAI GPT-5.2 + tool..." opencode run -s "$SID" -m openai/gpt-5.2-medium \ "Run: echo 'Test3-OpenAI'" \ > /tmp/e2e-t3-s2.log 2>&1 || true if check_signature_error /tmp/e2e-t3-s2.log; then log_fail "Test 3 - Invalid signature error (Gemini → OpenAI)" elif grep -qi "api.*key\|unauthorized\|authentication" /tmp/e2e-t3-s2.log; then log_skip "Test 3 - OpenAI API key issue (not signature related)" else log_pass "Test 3 - Gemini → OpenAI" fi fi echo "" # Test 4: Anthropic Claude → Gemini (reverse) echo "Test 4: Anthropic Claude → Gemini (reverse direction)" log_info "Step 1: Anthropic Claude with tool..." opencode run -m anthropic/claude-opus-4-5 \ "Run: echo 'Test4-Anthropic-Start'" \ > /tmp/e2e-t4-s1.log 2>&1 || true SID=$(get_session_id) if [ -z "$SID" ]; then log_fail "Test 4 - No session ID" else log_info "Session: $SID" log_info "Step 2: Gemini + thinking + tool..." opencode run -s "$SID" -m google/antigravity-gemini-3-pro-low \ "Run: echo 'Test4-Gemini'. Think about reversal." \ > /tmp/e2e-t4-s2.log 2>&1 || true if check_signature_error /tmp/e2e-t4-s2.log; then log_fail "Test 4 - Invalid signature error (Anthropic Claude → Gemini)" else log_pass "Test 4 - Anthropic Claude → Gemini" fi fi echo "" # Test 5: OpenAI → Google Claude echo "Test 5: OpenAI → Google Claude Opus Thinking" log_info "Step 1: OpenAI with tool..." opencode run -m openai/gpt-5.2-medium \ "Run: echo 'Test5-OpenAI-Start'" \ > /tmp/e2e-t5-s1.log 2>&1 || true if grep -qi "api.*key\|unauthorized\|authentication" /tmp/e2e-t5-s1.log; then log_skip "Test 5 - OpenAI API key issue" else SID=$(get_session_id) if [ -z "$SID" ]; then log_fail "Test 5 - No session ID" else log_info "Session: $SID" log_info "Step 2: Google Claude Opus Thinking + tool..." opencode run -s "$SID" -m google/antigravity-claude-opus-4-6-thinking-low \ "Run: echo 'Test5-Google-Claude'" \ > /tmp/e2e-t5-s2.log 2>&1 || true if check_signature_error /tmp/e2e-t5-s2.log; then log_fail "Test 5 - Invalid signature error (OpenAI → Google Claude)" else log_pass "Test 5 - OpenAI → Google Claude" fi fi fi echo "" # Test 6: 5-Model Round-Robin (all models in sequence) echo "Test 6: 5-Model Round-Robin" log_info "Turn 1: Gemini Pro Low..." opencode run -m google/antigravity-gemini-3-pro-low \ "Run: echo 'Turn1'. Think about the chain." \ > /tmp/e2e-t6-s1.log 2>&1 || true SID=$(get_session_id) if [ -z "$SID" ]; then log_fail "Test 6 - No session ID" else log_info "Session: $SID" CHAIN_OK=true log_info "Turn 2: Anthropic Claude..." opencode run -s "$SID" -m anthropic/claude-opus-4-5 \ "Run: echo 'Turn2'" > /tmp/e2e-t6-s2.log 2>&1 || true check_signature_error /tmp/e2e-t6-s2.log && CHAIN_OK=false log_info "Turn 3: Google Claude Opus..." opencode run -s "$SID" -m google/antigravity-claude-opus-4-6-thinking-low \ "Run: echo 'Turn3'" > /tmp/e2e-t6-s3.log 2>&1 || true check_signature_error /tmp/e2e-t6-s3.log && CHAIN_OK=false log_info "Turn 4: OpenAI GPT-5.2..." opencode run -s "$SID" -m openai/gpt-5.2-medium \ "Run: echo 'Turn4'" > /tmp/e2e-t6-s4.log 2>&1 || true # Skip OpenAI check if API key issue if ! grep -qi "api.*key\|unauthorized" /tmp/e2e-t6-s4.log; then check_signature_error /tmp/e2e-t6-s4.log && CHAIN_OK=false fi log_info "Turn 5: Gemini Flash..." opencode run -s "$SID" -m google/antigravity-gemini-3-flash \ "Run: echo 'Turn5-Complete'" > /tmp/e2e-t6-s5.log 2>&1 || true check_signature_error /tmp/e2e-t6-s5.log && CHAIN_OK=false if $CHAIN_OK; then log_pass "Test 6 - 5-Model Round-Robin" else log_fail "Test 6 - 5-Model Round-Robin (signature error in chain)" fi fi echo "" # Test 7: Google Claude → Anthropic Claude (same family, different API) echo "Test 7: Google Claude → Anthropic Claude (same family)" log_info "Step 1: Google Claude Opus Thinking..." opencode run -m google/antigravity-claude-opus-4-6-thinking-low \ "Run: echo 'Test7-Google-Claude'" \ > /tmp/e2e-t7-s1.log 2>&1 || true SID=$(get_session_id) if [ -z "$SID" ]; then log_fail "Test 7 - No session ID" else log_info "Session: $SID" log_info "Step 2: Anthropic Claude Opus..." opencode run -s "$SID" -m anthropic/claude-opus-4-5 \ "Run: echo 'Test7-Anthropic-Claude'" \ > /tmp/e2e-t7-s2.log 2>&1 || true if check_signature_error /tmp/e2e-t7-s2.log; then log_fail "Test 7 - Invalid signature error (Google Claude → Anthropic Claude)" else log_pass "Test 7 - Google Claude → Anthropic Claude" fi fi echo "" # Test 8: Triple switch with different model families echo "Test 8: Triple Switch (Gemini → Anthropic → OpenAI)" log_info "Step 1: Gemini Flash..." opencode run -m google/antigravity-gemini-3-flash \ "Run: echo 'Triple-1'. Think about it." \ > /tmp/e2e-t8-s1.log 2>&1 || true SID=$(get_session_id) if [ -z "$SID" ]; then log_fail "Test 8 - No session ID" else log_info "Session: $SID" TRIPLE_OK=true log_info "Step 2: Anthropic Claude..." opencode run -s "$SID" -m anthropic/claude-opus-4-5 \ "Run: echo 'Triple-2'" > /tmp/e2e-t8-s2.log 2>&1 || true check_signature_error /tmp/e2e-t8-s2.log && TRIPLE_OK=false log_info "Step 3: OpenAI..." opencode run -s "$SID" -m openai/gpt-5.2-medium \ "Run: echo 'Triple-3'" > /tmp/e2e-t8-s3.log 2>&1 || true if ! grep -qi "api.*key\|unauthorized" /tmp/e2e-t8-s3.log; then check_signature_error /tmp/e2e-t8-s3.log && TRIPLE_OK=false fi if $TRIPLE_OK; then log_pass "Test 8 - Triple Switch" else log_fail "Test 8 - Triple Switch (signature error)" fi fi echo "" echo "════════════════════════════════════════════════════════════" echo " Test Results Summary" echo "════════════════════════════════════════════════════════════" echo -e " ${GREEN}Passed${NC}: $PASS" echo -e " ${RED}Failed${NC}: $FAIL" echo -e " ${YELLOW}Skipped${NC}: $SKIP" echo "" if [ $FAIL -gt 0 ]; then echo -e "${RED}Some tests failed!${NC} Check /tmp/e2e-t*.log for details" exit 1 else echo -e "${GREEN}All tests passed!${NC}" exit 0 fi ================================================ FILE: script/test-cross-model.ts ================================================ #!/usr/bin/env npx tsx import { sanitizeCrossModelPayload, getModelFamily, } from '../src/plugin/transform/cross-model-sanitizer'; const GEMINI_THOUGHT_SIGNATURE = 'EsgQCsUQAXLI2nybuafAE150LGTo2r78fakesig123abc456def789'; const geminiHistoryWithThinkingAndToolCall = { contents: [ { role: 'user', parts: [{ text: 'Check disk space. Think about which filesystems are most utilized.' }] }, { role: 'model', parts: [ { thought: true, text: 'Let me analyze the disk usage by running df -h to see filesystem utilization...', thoughtSignature: GEMINI_THOUGHT_SIGNATURE }, { functionCall: { name: 'Bash', args: { command: 'df -h', description: 'Check disk space' } }, metadata: { google: { thoughtSignature: GEMINI_THOUGHT_SIGNATURE } } } ] }, { role: 'function', parts: [{ functionResponse: { name: 'Bash', response: { output: 'Filesystem Size Used Avail Use% Mounted on\n/dev/sda1 100G 62G 38G 62% /' } } }] }, { role: 'model', parts: [{ text: 'The root filesystem is 62% utilized, which is moderate usage.' }] } ] }; function runTests(): void { console.log('=== Cross-Model Sanitization E2E Test ===\n'); let passed = 0; let failed = 0; console.log('Test 1: Model family detection'); const geminiFamily = getModelFamily('gemini-3-pro-low'); const claudeFamily = getModelFamily('claude-opus-4-6-thinking-medium'); if (geminiFamily === 'gemini' && claudeFamily === 'claude') { console.log(' ✅ PASS: Model families detected correctly'); passed++; } else { console.log(` ❌ FAIL: Expected gemini/claude, got ${geminiFamily}/${claudeFamily}`); failed++; } console.log('\nTest 2: Gemini → Claude sanitization (exact bug reproduction)'); console.log(' Input: Gemini session with thinking + tool call containing thoughtSignature'); const result = sanitizeCrossModelPayload(geminiHistoryWithThinkingAndToolCall, { targetModel: 'claude-opus-4-6-thinking-medium' }); const payload = result.payload as any; const modelParts = payload.contents[1].parts; const thinkingPart = modelParts[0]; const toolPart = modelParts[1]; if (thinkingPart.thoughtSignature === undefined) { console.log(' ✅ PASS: Top-level thoughtSignature stripped from thinking part'); passed++; } else { console.log(' ❌ FAIL: thoughtSignature still present on thinking part'); failed++; } if (toolPart.metadata?.google?.thoughtSignature === undefined) { console.log(' ✅ PASS: Nested metadata.google.thoughtSignature stripped from tool part'); passed++; } else { console.log(' ❌ FAIL: metadata.google.thoughtSignature still present'); failed++; } if (toolPart.functionCall?.name === 'Bash') { console.log(' ✅ PASS: functionCall structure preserved'); passed++; } else { console.log(' ❌ FAIL: functionCall corrupted'); failed++; } if (result.modified && result.signaturesStripped === 2) { console.log(` ✅ PASS: Sanitization metrics correct (modified=true, stripped=${result.signaturesStripped})`); passed++; } else { console.log(` ❌ FAIL: Metrics incorrect (modified=${result.modified}, stripped=${result.signaturesStripped})`); failed++; } console.log('\nTest 3: Same model family - no sanitization'); const sameFamily = sanitizeCrossModelPayload(geminiHistoryWithThinkingAndToolCall, { targetModel: 'gemini-3-flash' }); if (!sameFamily.modified && sameFamily.signaturesStripped === 0) { console.log(' ✅ PASS: No sanitization for same model family'); passed++; } else { console.log(' ❌ FAIL: Should not sanitize same model family'); failed++; } console.log('\n=== Results ==='); console.log(`Passed: ${passed}/${passed + failed}`); console.log(`Failed: ${failed}/${passed + failed}`); if (failed > 0) { console.log('\n❌ Some tests failed'); process.exit(1); } else { console.log('\n✅ All E2E tests passed'); } } runTests(); ================================================ FILE: script/test-gemini-cli-e2e.sh ================================================ #!/bin/bash # Gemini CLI E2E Test Suite # Tests gemini-cli models routing through cloudcode-pa.googleapis.com/v1internal # # Models tested: # 1. google/gemini-2.5-pro # 2. google/gemini-2.5-flash # 3. google/gemini-3-pro-preview # 4. google/gemini-3-flash-preview set -euo pipefail PASS=0 FAIL=0 SKIP=0 GREEN='\033[0;32m' RED='\033[0;31m' YELLOW='\033[0;33m' BLUE='\033[0;34m' NC='\033[0m' log_pass() { echo -e "${GREEN}✓ PASS${NC}: $1"; ((PASS++)); } log_fail() { echo -e "${RED}✗ FAIL${NC}: $1"; ((FAIL++)); } log_skip() { echo -e "${YELLOW}○ SKIP${NC}: $1"; ((SKIP++)); } log_info() { echo -e " ${BLUE}→${NC} $1"; } # Check for common errors check_auth_error() { grep -qiE "insufficient.*scope|authentication|unauthorized|403|401" "$1" 2>/dev/null && return 0 || return 1 } check_quota_error() { grep -qiE "quota|rate.limit|429|resource.exhausted" "$1" 2>/dev/null && return 0 || return 1 } check_model_error() { grep -qiE "model.*not.found|invalid.*model|404" "$1" 2>/dev/null && return 0 || return 1 } # Test a single model test_model() { local model="$1" local test_name="$2" local log_file="/tmp/gemini-cli-e2e-${test_name}.log" log_info "Testing $model..." # Run opencode with a simple prompt timeout 60 opencode run -m "$model" \ "Reply with exactly: GEMINI_CLI_OK" \ 2>&1 > "$log_file" || true # Check for various error conditions if check_auth_error "$log_file"; then log_fail "$test_name - Authentication/scope error (check OAuth scopes)" log_info "This likely means routing to wrong endpoint" return 1 elif check_quota_error "$log_file"; then log_skip "$test_name - Quota exhausted (not a routing issue)" return 0 elif check_model_error "$log_file"; then log_fail "$test_name - Model not found" return 1 elif grep -qi "GEMINI_CLI_OK\|working\|ok\|hello" "$log_file"; then log_pass "$test_name" return 0 elif grep -qi "error\|exception\|failed" "$log_file"; then log_fail "$test_name - Unknown error" log_info "Check $log_file for details" return 1 else # No obvious error, assume success log_pass "$test_name" return 0 fi } echo "════════════════════════════════════════════════════════════" echo " Gemini CLI E2E Test Suite" echo " Testing cloudcode-pa.googleapis.com/v1internal routing" echo "════════════════════════════════════════════════════════════" echo "" echo "Test 1: google/gemini-2.5-flash" test_model "google/gemini-2.5-flash" "gemini-2.5-flash" || true echo "" echo "Test 2: google/gemini-2.5-pro" test_model "google/gemini-2.5-pro" "gemini-2.5-pro" || true echo "" echo "Test 3: google/gemini-3-flash-preview" test_model "google/gemini-3-flash-preview" "gemini-3-flash-preview" || true echo "" echo "Test 4: google/gemini-3-pro-preview" test_model "google/gemini-3-pro-preview" "gemini-3-pro-preview" || true echo "" # Test 5: Cross-model session (gemini-cli → antigravity) echo "Test 5: Cross-model session (gemini-cli → antigravity-gemini)" log_info "Step 1: Start with gemini-2.5-flash..." timeout 60 opencode run -m google/gemini-2.5-flash \ "Say: SESSION_START" \ 2>&1 > /tmp/gemini-cli-e2e-cross-s1.log || true # Get session ID sleep 1 SID=$(opencode session list 2>/dev/null | grep -oP 'ses_[a-zA-Z0-9]+' | head -1 || true) if [ -z "$SID" ]; then log_fail "Test 5 - No session ID created" else log_info "Session: $SID" log_info "Step 2: Switch to antigravity-gemini-3-flash..." timeout 60 opencode run -s "$SID" -m google/antigravity-gemini-3-flash \ "Say: SESSION_CONTINUE" \ 2>&1 > /tmp/gemini-cli-e2e-cross-s2.log || true if check_auth_error /tmp/gemini-cli-e2e-cross-s2.log; then log_fail "Test 5 - Auth error on cross-model switch" else log_pass "Test 5 - Cross-model session (gemini-cli → antigravity)" fi fi echo "" # Test 6: Reverse cross-model (antigravity → gemini-cli) echo "Test 6: Cross-model session (antigravity → gemini-cli)" log_info "Step 1: Start with antigravity-gemini-3-pro-low..." timeout 60 opencode run -m google/antigravity-gemini-3-pro-low \ "Say: ANTIGRAVITY_START" \ 2>&1 > /tmp/gemini-cli-e2e-reverse-s1.log || true sleep 1 SID=$(opencode session list 2>/dev/null | grep -oP 'ses_[a-zA-Z0-9]+' | head -1 || true) if [ -z "$SID" ]; then log_fail "Test 6 - No session ID created" else log_info "Session: $SID" log_info "Step 2: Switch to gemini-2.5-pro..." timeout 60 opencode run -s "$SID" -m google/gemini-2.5-pro \ "Say: GEMINI_CLI_CONTINUE" \ 2>&1 > /tmp/gemini-cli-e2e-reverse-s2.log || true if check_auth_error /tmp/gemini-cli-e2e-reverse-s2.log; then log_fail "Test 6 - Auth error on reverse cross-model switch" else log_pass "Test 6 - Cross-model session (antigravity → gemini-cli)" fi fi echo "" echo "════════════════════════════════════════════════════════════" echo " Test Results Summary" echo "════════════════════════════════════════════════════════════" echo -e " ${GREEN}Passed${NC}: $PASS" echo -e " ${RED}Failed${NC}: $FAIL" echo -e " ${YELLOW}Skipped${NC}: $SKIP" echo "" if [ $FAIL -gt 0 ]; then echo -e "${RED}Some tests failed!${NC}" echo "Log files: /tmp/gemini-cli-e2e-*.log" exit 1 else echo -e "${GREEN}All Gemini CLI tests passed!${NC}" exit 0 fi ================================================ FILE: script/test-models.ts ================================================ #!/usr/bin/env npx tsx import { spawn } from "child_process"; interface ModelTest { model: string; category: "gemini-cli" | "antigravity-gemini" | "antigravity-claude"; } const MODELS: ModelTest[] = [ // Gemini CLI (direct Google API) { model: "google/gemini-3-flash-preview", category: "gemini-cli" }, { model: "google/gemini-3-pro-preview", category: "gemini-cli" }, { model: "google/gemini-2.5-pro", category: "gemini-cli" }, { model: "google/gemini-2.5-flash", category: "gemini-cli" }, // Antigravity Gemini { model: "google/antigravity-gemini-3-pro-low", category: "antigravity-gemini" }, { model: "google/antigravity-gemini-3-pro-high", category: "antigravity-gemini" }, { model: "google/antigravity-gemini-3-flash", category: "antigravity-gemini" }, // Antigravity Claude { model: "google/antigravity-claude-sonnet-4-6", category: "antigravity-claude" }, { model: "google/antigravity-claude-opus-4-6-thinking-low", category: "antigravity-claude" }, { model: "google/antigravity-claude-opus-4-6-thinking-medium", category: "antigravity-claude" }, { model: "google/antigravity-claude-opus-4-6-thinking-high", category: "antigravity-claude" }, ]; const TEST_PROMPT = "Reply with exactly one word: WORKING"; const DEFAULT_TIMEOUT_MS = 120_000; interface TestResult { success: boolean; error?: string; duration: number; } async function testModel(model: string, timeoutMs: number): Promise { const start = Date.now(); return new Promise((resolve) => { const proc = spawn("opencode", ["run", TEST_PROMPT, "--model", model], { stdio: ["ignore", "pipe", "pipe"], }); let stdout = ""; let stderr = ""; const timer = setTimeout(() => { proc.kill("SIGKILL"); resolve({ success: false, error: `Timeout after ${timeoutMs}ms`, duration: Date.now() - start }); }, timeoutMs); proc.stdout?.on("data", (data) => { stdout += data.toString(); }); proc.stderr?.on("data", (data) => { stderr += data.toString(); }); proc.on("close", (code) => { clearTimeout(timer); const duration = Date.now() - start; if (code !== 0) { resolve({ success: false, error: `Exit ${code}: ${stderr || stdout}`.slice(0, 200), duration }); } else { resolve({ success: true, duration }); } }); proc.on("error", (err) => { clearTimeout(timer); resolve({ success: false, error: err.message, duration: Date.now() - start }); }); }); } function parseArgs(): { filterModel: string | null; filterCategory: string | null; dryRun: boolean; help: boolean; timeout: number } { const args = process.argv.slice(2); const modelIdx = args.indexOf("--model"); const catIdx = args.indexOf("--category"); const timeoutIdx = args.indexOf("--timeout"); return { filterModel: modelIdx !== -1 ? args[modelIdx + 1] ?? null : null, filterCategory: catIdx !== -1 ? args[catIdx + 1] ?? null : null, dryRun: args.includes("--dry-run"), help: args.includes("--help") || args.includes("-h"), timeout: timeoutIdx !== -1 ? parseInt(args[timeoutIdx + 1] || "120000", 10) : DEFAULT_TIMEOUT_MS, }; } function printHelp(): void { console.log(` E2E Model Test Script Usage: npx tsx script/test-models.ts [options] Options: --model Test specific model --category Test by category (gemini-cli, antigravity-gemini, antigravity-claude) --timeout Timeout per model (default: 120000) --dry-run List models without testing --help, -h Show this help Examples: npx tsx script/test-models.ts --dry-run npx tsx script/test-models.ts --model google/gemini-3-flash-preview npx tsx script/test-models.ts --category antigravity-claude `); } async function main(): Promise { const { filterModel, filterCategory, dryRun, help, timeout } = parseArgs(); if (help) { printHelp(); return; } let tests = MODELS; if (filterModel) tests = tests.filter((t) => t.model === filterModel || t.model.endsWith(filterModel)); if (filterCategory) tests = tests.filter((t) => t.category === filterCategory); if (tests.length === 0) { console.log("No models match the filter."); return; } console.log(`\n🧪 E2E Model Tests (${tests.length} models)\n${"=".repeat(50)}\n`); if (dryRun) { for (const t of tests) { console.log(` ${t.model.padEnd(50)} [${t.category}]`); } console.log(`\n${tests.length} models would be tested.\n`); return; } let passed = 0; let failed = 0; const failures: { model: string; error: string }[] = []; for (const t of tests) { process.stdout.write(`Testing ${t.model.padEnd(50)} ... `); const result = await testModel(t.model, timeout); if (result.success) { console.log(`✅ (${(result.duration / 1000).toFixed(1)}s)`); passed++; } else { console.log(`❌ FAIL`); console.log(` ${result.error}`); failures.push({ model: t.model, error: result.error || "Unknown" }); failed++; } } console.log(`\n${"=".repeat(50)}`); console.log(`Summary: ${passed} passed, ${failed} failed\n`); if (failures.length > 0) { console.log("Failed models:"); for (const f of failures) { console.log(` - ${f.model}`); } process.exit(1); } } main().catch(console.error); ================================================ FILE: script/test-regression.ts ================================================ #!/usr/bin/env npx tsx import { spawn } from "child_process"; type Category = "thinking-order" | "tool-pairing" | "multi-tool" | "multi-provider" | "error-handling" | "stress" | "concurrency"; type TestSuite = "sanity" | "heavy" | "all"; interface MultiTurnTest { name: string; model: string; category: Category; suite: TestSuite; turns: (string | TurnConfig)[]; errorPatterns: string[]; timeout: number; expectError?: string; } interface TurnConfig { prompt: string; model?: string; } interface TestResult { success: boolean; error?: string; duration: number; turnsCompleted: number; sessionId?: string; } interface ConcurrentTest { name: string; category: "concurrency"; suite: TestSuite; concurrentRequests: number; model: string; prompt: string; errorPatterns: string[]; timeout: number; } const ERROR_PATTERNS = [ "thinking block order", "Expected thinking or redacted_thinking", "tool_use ids were found without tool_result", "tool_result_missing", "thinking_disabled_violation", "orphaned tool_use", "must start with thinking block", "error: tool_use without matching tool_result", "cannot be modified", "must remain as they were", ]; const GEMINI_FLASH = "google/antigravity-gemini-3-flash"; const GEMINI_FLASH_CLI_QUOTA = "google/gemini-2.5-flash"; const CLAUDE_SONNET = "google/antigravity-claude-sonnet-4-6"; const CLAUDE_OPUS = "google/antigravity-claude-opus-4-6-thinking-low"; const SANITY_TESTS: MultiTurnTest[] = [ { name: "thinking-tool-use", model: CLAUDE_SONNET, category: "thinking-order", suite: "sanity", turns: ["Read package.json and tell me the package name"], errorPatterns: ERROR_PATTERNS, timeout: 90000, }, { name: "thinking-bash-tool", model: CLAUDE_SONNET, category: "thinking-order", suite: "sanity", turns: ["Run: echo 'hello' and tell me the output"], errorPatterns: ERROR_PATTERNS, timeout: 90000, }, { name: "tool-pairing-sequential", model: CLAUDE_SONNET, category: "tool-pairing", suite: "sanity", turns: ["Run: echo 'first'", "Run: echo 'second'"], errorPatterns: ERROR_PATTERNS, timeout: 120000, }, { name: "opus-thinking-basic", model: CLAUDE_OPUS, category: "thinking-order", suite: "sanity", turns: ["What is 7 * 8? Use bash to verify: echo $((7*8))"], errorPatterns: ERROR_PATTERNS, timeout: 120000, }, { name: "thinking-modification-continue", model: CLAUDE_SONNET, category: "thinking-order", suite: "sanity", turns: [ "Read package.json and tell me the version", "Now read tsconfig.json and tell me the target", "Compare the two files briefly", ], errorPatterns: ERROR_PATTERNS, timeout: 120000, }, { name: "multi-provider-switch", model: GEMINI_FLASH, category: "multi-provider", suite: "sanity", turns: [ { prompt: "What is 2+2? Answer briefly.", model: GEMINI_FLASH }, { prompt: "What is 3+3? Answer briefly.", model: CLAUDE_SONNET }, { prompt: "What is 4+4? Answer briefly.", model: GEMINI_FLASH }, ], errorPatterns: ERROR_PATTERNS, timeout: 180000, }, { name: "prompt-too-long-recovery", model: GEMINI_FLASH, category: "error-handling", suite: "sanity", turns: ["Reply with exactly: OK", "Repeat the word 'test' 50000 times"], errorPatterns: ["FATAL", "unhandled", "Cannot read properties"], timeout: 60000, }, ]; const HEAVY_TESTS: MultiTurnTest[] = [ { name: "stress-8-turn-multi-provider", model: GEMINI_FLASH, category: "stress", suite: "heavy", turns: [ { prompt: "Read package.json and tell me the name", model: GEMINI_FLASH }, { prompt: "Now read tsconfig.json and tell me the target", model: CLAUDE_SONNET }, { prompt: "Run: ls -la src/plugin | head -5", model: GEMINI_FLASH }, { prompt: "Read src/plugin/auth.ts and summarize in 1 sentence", model: CLAUDE_SONNET }, { prompt: "Run: wc -l src/plugin/*.ts | tail -3", model: GEMINI_FLASH }, { prompt: "Read README.md first 50 lines and tell me what this project does", model: CLAUDE_SONNET }, { prompt: "Run: git log --oneline -3", model: GEMINI_FLASH }, { prompt: "Summarize everything we discussed in 3 bullet points", model: CLAUDE_SONNET }, ], errorPatterns: ERROR_PATTERNS, timeout: 600000, }, { name: "opencode-tools-comprehensive", model: CLAUDE_SONNET, category: "multi-tool", suite: "heavy", turns: [ "Use glob to find all *.ts files in src/plugin directory", "Use grep to search for 'async function' in src/plugin/auth.ts", "Use bash to run: echo 'test123' && pwd", "Use read to read the first 20 lines of package.json", "Use lsp_diagnostics on src/plugin/auth.ts to check for errors", "Use glob to find all test files matching *.test.ts", ], errorPatterns: ERROR_PATTERNS, timeout: 480000, }, { name: "stress-20-turn-recovery", model: GEMINI_FLASH, category: "stress", suite: "heavy", turns: [ { prompt: "Read package.json and extract the version number only", model: GEMINI_FLASH }, { prompt: "Run: ls src/plugin/*.ts | head -3", model: CLAUDE_SONNET }, { prompt: "Read src/plugin/auth.ts first 30 lines", model: GEMINI_FLASH }, { prompt: "Use grep to find 'export' in src/plugin/auth.ts", model: CLAUDE_SONNET }, { prompt: "Run: echo 'checkpoint 1' && date", model: GEMINI_FLASH }, { prompt: "Read tsconfig.json and tell me the module type", model: CLAUDE_SONNET }, { prompt: "Use glob to find all *.test.ts files", model: GEMINI_FLASH }, { prompt: "Read src/plugin/token.ts first 20 lines", model: CLAUDE_SONNET }, { prompt: "Run: wc -l src/plugin/*.ts | sort -n | tail -5", model: GEMINI_FLASH }, { prompt: "What files have we read so far? List them.", model: CLAUDE_SONNET }, { prompt: "Read src/plugin/request.ts first 25 lines", model: GEMINI_FLASH }, { prompt: "Use grep to find 'async' in src/plugin/request.ts", model: CLAUDE_SONNET }, { prompt: "Run: echo 'checkpoint 2' && pwd", model: GEMINI_FLASH }, { prompt: "Read src/plugin/storage.ts first 20 lines", model: CLAUDE_SONNET }, { prompt: "Use lsp_diagnostics on src/plugin/token.ts", model: GEMINI_FLASH }, { prompt: "Read vitest.config.ts completely", model: CLAUDE_SONNET }, { prompt: "Run: git status --short | head -5", model: GEMINI_FLASH }, { prompt: "Read src/constants.ts completely", model: CLAUDE_SONNET }, { prompt: "Run: echo 'final checkpoint' && echo 'all done'", model: GEMINI_FLASH }, { prompt: "Summarize this entire conversation in 5 bullet points", model: CLAUDE_SONNET }, ], errorPatterns: ERROR_PATTERNS, timeout: 900000, }, { name: "stress-50-turn-endurance", model: GEMINI_FLASH, category: "stress", suite: "heavy", turns: generateEnduranceTest(50), errorPatterns: ERROR_PATTERNS, timeout: 1800000, }, ]; function generateEnduranceTest(turnCount: number): TurnConfig[] { const turns: TurnConfig[] = []; const prompts = [ { prompt: "What is {n} + {n}? Answer with just the number.", model: GEMINI_FLASH }, { prompt: "Run: echo 'turn {i}'", model: CLAUDE_SONNET }, { prompt: "Read package.json and tell me one field", model: GEMINI_FLASH }, { prompt: "Run: pwd && echo 'ok'", model: CLAUDE_SONNET }, { prompt: "What turn number are we on? Just say the number.", model: GEMINI_FLASH }, { prompt: "Run: date +%H:%M:%S", model: CLAUDE_SONNET }, { prompt: "Use glob to find one .ts file in src/", model: GEMINI_FLASH }, { prompt: "Run: echo 'checkpoint {i}'", model: CLAUDE_SONNET }, { prompt: "Read tsconfig.json and tell me target", model: GEMINI_FLASH }, { prompt: "What have we done in last 3 turns? Brief answer.", model: CLAUDE_SONNET }, ]; for (let i = 0; i < turnCount; i++) { const template = prompts[i % prompts.length]!; const prompt = template.prompt .replace(/\{i\}/g, String(i + 1)) .replace(/\{n\}/g, String(i + 1)); turns.push({ prompt, model: template.model }); } turns.push({ prompt: `We completed ${turnCount} turns. Summarize this session in 3 sentences.`, model: CLAUDE_SONNET, }); return turns; } const RATE_LIMIT_ERROR_PATTERNS = [ "false alarm", "incorrectly marked as rate limited", "wrong quota", ]; const CONCURRENT_TESTS: ConcurrentTest[] = [ { name: "concurrent-5-same-model", category: "concurrency", suite: "heavy", concurrentRequests: 5, model: GEMINI_FLASH, prompt: "What is 2+2? Answer with just the number.", errorPatterns: [...ERROR_PATTERNS, ...RATE_LIMIT_ERROR_PATTERNS], timeout: 120000, }, { name: "concurrent-3-mixed-models", category: "concurrency", suite: "heavy", concurrentRequests: 3, model: GEMINI_FLASH, prompt: "Say hello in one word.", errorPatterns: [...ERROR_PATTERNS, ...RATE_LIMIT_ERROR_PATTERNS], timeout: 120000, }, { name: "concurrent-10-antigravity-heavy", category: "concurrency", suite: "heavy", concurrentRequests: 10, model: GEMINI_FLASH, prompt: "What is 1+1? Answer with just the number.", errorPatterns: [...ERROR_PATTERNS, ...RATE_LIMIT_ERROR_PATTERNS], timeout: 180000, }, ]; const ALL_TESTS = [...SANITY_TESTS, ...HEAVY_TESTS]; async function runTurn( prompt: string, model: string, sessionId: string | null, sessionTitle: string, timeout: number ): Promise<{ output: string; stderr: string; code: number; sessionId: string | null }> { return new Promise((resolve) => { const args = sessionId ? ["run", prompt, "--session", sessionId, "--model", model] : ["run", prompt, "--model", model, "--title", sessionTitle]; const proc = spawn("opencode", args, { stdio: ["ignore", "pipe", "pipe"], cwd: process.cwd(), }); let stdout = ""; let stderr = ""; proc.stdout?.on("data", (data) => { stdout += data.toString(); }); proc.stderr?.on("data", (data) => { stderr += data.toString(); }); const timeoutId = setTimeout(() => { proc.kill("SIGTERM"); }, timeout); proc.on("close", (code) => { clearTimeout(timeoutId); let extractedSessionId = sessionId; if (!extractedSessionId) { const match = stdout.match(/session[:\s]+([a-zA-Z0-9_-]+)/i) || stderr.match(/session[:\s]+([a-zA-Z0-9_-]+)/i); if (match) { extractedSessionId = match[1] ?? null; } } resolve({ output: stdout, stderr: stderr, code: code ?? 1, sessionId: extractedSessionId, }); }); proc.on("error", (err) => { clearTimeout(timeoutId); resolve({ output: "", stderr: err.message, code: 1, sessionId: null, }); }); }); } async function deleteSession(sessionId: string): Promise { return new Promise((resolve) => { const proc = spawn("opencode", ["session", "delete", sessionId, "--force"], { stdio: ["ignore", "pipe", "pipe"], timeout: 10000, cwd: process.cwd(), }); proc.on("close", () => resolve()); proc.on("error", () => resolve()); }); } async function runConcurrentTest(test: ConcurrentTest): Promise { const start = Date.now(); const sessionIds: string[] = []; process.stdout.write(` Spawning ${test.concurrentRequests} concurrent requests...`); const promises = Array.from({ length: test.concurrentRequests }, (_, i) => runTurn( `${test.prompt} (request ${i + 1})`, test.model, null, `concurrent-${test.name}-${i}`, test.timeout ) ); const results = await Promise.all(promises); process.stdout.write("\r" + " ".repeat(60) + "\r"); for (const result of results) { if (result.sessionId) { sessionIds.push(result.sessionId); } } for (const result of results) { for (const pattern of test.errorPatterns) { if (result.stderr.toLowerCase().includes(pattern.toLowerCase())) { for (const sid of sessionIds) { await deleteSession(sid); } return { success: false, error: `Found error pattern "${pattern}" in concurrent response`, duration: Date.now() - start, turnsCompleted: 0, }; } } } const failedResults = results.filter((r) => r.code !== 0); const failedCount = failedResults.length; if (failedCount > test.concurrentRequests / 2) { for (const sid of sessionIds) { await deleteSession(sid); } const firstFailure = failedResults[0]; const failureDetails = firstFailure ? `\n First failure stderr: ${firstFailure.stderr.slice(0, 500)}` : ""; return { success: false, error: `${failedCount}/${test.concurrentRequests} requests failed${failureDetails}`, duration: Date.now() - start, turnsCompleted: test.concurrentRequests - failedCount, }; } for (const sid of sessionIds) { await deleteSession(sid); } return { success: true, duration: Date.now() - start, turnsCompleted: test.concurrentRequests, }; } async function runMultiTurnTest(test: MultiTurnTest): Promise { const start = Date.now(); let sessionId: string | null = null; let turnsCompleted = 0; for (let index = 0; index < test.turns.length; index++) { const turn = test.turns[index]!; const prompt = typeof turn === "string" ? turn : turn.prompt; const model = typeof turn === "string" ? test.model : (turn.model ?? test.model); const turnStart = Date.now(); process.stdout.write(`\r Progress: ${index + 1}/${test.turns.length} turns...`); const result = await runTurn( prompt, model, sessionId ?? null, `regression-${test.name}`, test.timeout ); for (const pattern of test.errorPatterns) { if (result.stderr.toLowerCase().includes(pattern.toLowerCase())) { process.stdout.write("\r" + " ".repeat(50) + "\r"); return { success: false, error: `Turn ${index + 1}: Found error pattern "${pattern}"`, duration: Date.now() - start, turnsCompleted, sessionId: sessionId ?? undefined, }; } } if (result.code !== 0 && result.code !== null) { const isTimeout = Date.now() - turnStart >= test.timeout - 1000; if (isTimeout) { process.stdout.write("\r" + " ".repeat(50) + "\r"); return { success: false, error: `Turn ${index + 1}: Timeout after ${test.timeout}ms`, duration: Date.now() - start, turnsCompleted, sessionId: sessionId ?? undefined, }; } } sessionId = result.sessionId; turnsCompleted++; } process.stdout.write("\r" + " ".repeat(50) + "\r"); return { success: true, duration: Date.now() - start, turnsCompleted, sessionId: sessionId ?? undefined, }; } function parseArgs(): { filterName: string | null; filterCategory: Category | null; suite: TestSuite; dryRun: boolean; help: boolean; } { const args = process.argv.slice(2); const getArg = (flag: string): string | null => { const idx = args.indexOf(flag); return idx !== -1 && args[idx + 1] !== undefined ? args[idx + 1]! : null; }; let suite: TestSuite = "all"; if (args.includes("--sanity")) suite = "sanity"; if (args.includes("--heavy")) suite = "heavy"; return { filterName: getArg("--test") ?? getArg("--name"), filterCategory: getArg("--category") as Category | null, suite, dryRun: args.includes("--dry-run"), help: args.includes("--help") || args.includes("-h"), }; } function showHelp(): void { console.log(` Multi-Turn Regression Test Suite for Antigravity Plugin Test Suites: --sanity Quick tests (7 tests, ~5 min) - run frequently --heavy Stress tests (4 tests, ~30 min) - long conversations (default) All tests Tests: Sanity (quick, repeatable): - thinking-tool-use, thinking-bash-tool, tool-pairing-sequential - opus-thinking-basic, thinking-modification-continue - multi-provider-switch, prompt-too-long-recovery Heavy (stress, endurance): - stress-8-turn-multi-provider (8 turns) - opencode-tools-comprehensive (6 turns, all tools) - stress-20-turn-recovery (20 turns, multi-model, recovery) - stress-50-turn-endurance (51 turns, endurance test) Usage: npx tsx script/test-regression.ts [options] Options: --sanity Run sanity tests only (quick) --heavy Run heavy tests only (stress) --test Run specific test by name --category Run tests by category --dry-run List tests without running --help, -h Show this help Examples: npx tsx script/test-regression.ts --sanity npx tsx script/test-regression.ts --heavy npx tsx script/test-regression.ts --test stress-20-turn-recovery `); } async function main(): Promise { const { filterName, filterCategory, suite, dryRun, help } = parseArgs(); if (help) { showHelp(); return; } let tests: MultiTurnTest[]; switch (suite) { case "sanity": tests = SANITY_TESTS; break; case "heavy": tests = HEAVY_TESTS; break; default: tests = ALL_TESTS; } if (filterName) { tests = tests.filter((t) => t.name === filterName); } if (filterCategory && filterCategory !== "concurrency") { tests = tests.filter((t) => t.category === filterCategory); } const runConcurrentOnly = filterCategory === "concurrency"; if (runConcurrentOnly) { tests = []; } if (tests.length === 0 && !runConcurrentOnly) { console.error("No tests match the specified filters"); process.exit(1); } const totalTurns = tests.reduce((sum, t) => sum + t.turns.length, 0); const concurrentCount = CONCURRENT_TESTS.reduce((sum, t) => sum + t.concurrentRequests, 0); console.log(`\n🧪 Regression Tests [${suite.toUpperCase()}] (${tests.length} tests, ${totalTurns} turns + ${concurrentCount} concurrent)\n${"=".repeat(60)}\n`); if (dryRun) { console.log("Tests to run:\n"); for (const test of tests) { console.log(` ${test.name} [${test.suite}]`); console.log(` Model: ${test.model}`); console.log(` Category: ${test.category}`); console.log(` Turns: ${test.turns.length}`); console.log(); } return; } const results: { test: MultiTurnTest; result: TestResult }[] = []; for (const test of tests) { console.log(`Testing: ${test.name} [${test.suite}]`); console.log(` Model: ${test.model}`); console.log(` Turns: ${test.turns.length}`); const result = await runMultiTurnTest(test); results.push({ test, result }); if (result.success) { console.log(` Status: ✅ PASS (${result.turnsCompleted}/${test.turns.length} turns, ${(result.duration / 1000).toFixed(1)}s)`); } else { console.log(` Status: ❌ FAIL`); console.log(` Error: ${result.error}`); console.log(` Completed: ${result.turnsCompleted}/${test.turns.length} turns`); } if (result.sessionId) { await deleteSession(result.sessionId); } console.log(); } if (suite === "heavy" || suite === "all" || runConcurrentOnly || filterName) { let concurrentTests = CONCURRENT_TESTS; if (filterName) { concurrentTests = concurrentTests.filter((t) => t.name === filterName); } if (concurrentTests.length === 0 && !runConcurrentOnly && tests.length === 0) { console.error("No tests match the specified filters"); process.exit(1); } if (concurrentTests.length > 0) { console.log(`\n🔄 Concurrent Tests (${concurrentTests.length} tests)\n${"-".repeat(40)}\n`); for (const test of concurrentTests) { console.log(`Testing: ${test.name} [concurrent]`); console.log(` Model: ${test.model}`); console.log(` Concurrent: ${test.concurrentRequests} requests`); const result = await runConcurrentTest(test); results.push({ test: test as unknown as MultiTurnTest, result }); if (result.success) { console.log(` Status: ✅ PASS (${result.turnsCompleted} requests, ${(result.duration / 1000).toFixed(1)}s)`); } else { console.log(` Status: ❌ FAIL`); console.log(` Error: ${result.error}`); } console.log(); } } } const passed = results.filter((r) => r.result.success).length; const failed = results.filter((r) => !r.result.success).length; const totalTime = results.reduce((sum, r) => sum + r.result.duration, 0); console.log("=".repeat(60)); console.log(`\nSummary: ${passed} passed, ${failed} failed (${(totalTime / 1000).toFixed(1)}s total)\n`); if (failed > 0) { console.log("Failed tests:"); for (const r of results.filter((r) => !r.result.success)) { console.log(` ❌ ${r.test.name}: ${r.result.error}`); } process.exit(1); } } main().catch((err) => { console.error("Fatal error:", err); process.exit(1); }); ================================================ FILE: scripts/README-PI.md ================================================ # Raspberry Pi Runner Setup Use your Raspberry Pi as a persistent, self-hosted runner for Opencode Triage. This enables the use of `gh copilot` and other tools without re-authenticating on every run. ## Prerequisites - A Raspberry Pi (3, 4, or 5) running Raspberry Pi OS (64-bit recommended) or Ubuntu. - Internet connection. - SSH access. ## Step 1: Get your Token 1. Go to your GitHub Repository. 2. Navigate to **Settings** > **Actions** > **Runners**. 3. Click **New self-hosted runner**. 4. Select **Linux** and **ARM64**. 5. Copy the **Token** shown in the "Configure" section (you'll need it in Step 2). ## Step 2: Run the Setup Script Copy the `scripts/` folder to your Pi (or just copy-paste the content). ```bash # On your Pi mkdir -p ~/opencode-setup cd ~/opencode-setup # (Copy scripts/setup-pi-runner.sh here) chmod +x setup-pi-runner.sh ./setup-pi-runner.sh ``` Follow the prompts to enter your Repo URL and Token. ## Step 3: Authenticate Tools To enable `gh copilot` and other AI tools, run the auth helper: ```bash # (Copy scripts/auth-pi-tools.sh here) chmod +x auth-pi-tools.sh ./auth-pi-tools.sh ``` Follow the interactive login flows. ## Step 4: Update Workflow Once your runner is "Idle" (green) in GitHub Settings, update your `.github/workflows/issue-triage.yml`: ```yaml runs-on: self-hosted # or specifically: # runs-on: [self-hosted, pi] ``` ================================================ FILE: scripts/auth-pi-tools.sh ================================================ #!/bin/bash set -e # Colors GREEN='\033[0;32m' BLUE='\033[0;34m' NC='\033[0m' echo -e "${BLUE}=== Authenticating Development Tools ===${NC}" echo "This script establishes the persistent sessions for your AI tools." # 1. GitHub & Copilot echo -e "${GREEN}[1/2] Authenticating GitHub & Copilot...${NC}" echo "Follow the browser login steps..." gh auth login -h github.com -p https -w echo "Installing Copilot extension..." gh extension install github/gh-copilot || true echo -e "${BLUE}NOTE: Copilot might require a separate auth step.${NC}" echo "Running a test command. If prompted, please authenticate." gh copilot explain "echo hello" || true # 2. Google Cloud (Optional) echo -e "${GREEN}[2/2] Authenticating Google Cloud (Optional)...${NC}" if command -v gcloud &> /dev/null; then gcloud auth login gcloud auth application-default login else echo "gcloud CLI not found. Skipping." echo "To install: curl https://sdk.cloud.google.com | bash" fi echo -e "${BLUE}=== Authentication Complete ===${NC}" echo "Your credentials are saved in ~/.config/" echo "The Runner service runs as 'root' or your user depending on setup." echo "Check that the runner user has access to these credentials." ================================================ FILE: scripts/check-quota.mjs ================================================ import { readFileSync } from "node:fs"; import { homedir } from "node:os"; import { join } from "node:path"; const CLIENT_ID = "1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com"; const CLIENT_SECRET = "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"; const CLOUD_CODE_BASE = "https://cloudcode-pa.googleapis.com"; const USER_AGENT = "antigravity/windows/amd64"; const FALLBACK_PROJECT_ID = "bamboo-precept-lgxtn"; function getDefaultAccountsPath() { if (process.platform === "win32") { const appData = process.env.APPDATA || join(homedir(), "AppData", "Roaming"); return join(appData, "opencode", "antigravity-accounts.json"); } const xdgConfig = process.env.XDG_CONFIG_HOME || join(homedir(), ".config"); return join(xdgConfig, "opencode", "antigravity-accounts.json"); } function parseArgs() { const args = process.argv.slice(2); let path = getDefaultAccountsPath(); let accountIndex = null; for (let i = 0; i < args.length; i += 1) { const arg = args[i]; if (arg === "--path" && args[i + 1]) { path = args[i + 1]; i += 1; continue; } if (arg === "--account" && args[i + 1]) { const parsed = Number.parseInt(args[i + 1], 10); if (!Number.isNaN(parsed)) { accountIndex = parsed - 1; } i += 1; } } return { path, accountIndex }; } async function postJson(url, token, body, extraHeaders = {}) { return fetch(url, { method: "POST", headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", "User-Agent": USER_AGENT, ...extraHeaders, }, body: JSON.stringify(body), }); } async function refreshAccessToken(refreshToken) { const response = await fetch("https://oauth2.googleapis.com/token", { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded", }, body: new URLSearchParams({ grant_type: "refresh_token", refresh_token: refreshToken, client_id: CLIENT_ID, client_secret: CLIENT_SECRET, }), }); if (!response.ok) { const text = await response.text().catch(() => ""); throw new Error(`Token refresh failed (${response.status}): ${text.slice(0, 200)}`); } const payload = await response.json(); return payload.access_token; } async function loadProjectId(accessToken) { const body = { metadata: { ideType: "ANTIGRAVITY" } }; const response = await postJson(`${CLOUD_CODE_BASE}/v1internal:loadCodeAssist`, accessToken, body); if (!response.ok) { return ""; } const payload = await response.json(); if (typeof payload.cloudaicompanionProject === "string") { return payload.cloudaicompanionProject; } if (payload.cloudaicompanionProject && typeof payload.cloudaicompanionProject.id === "string") { return payload.cloudaicompanionProject.id; } return ""; } function classifyGroup(modelName) { const lower = modelName.toLowerCase(); if (lower.includes("claude")) return "claude"; if (!lower.includes("gemini-3")) return null; if (lower.includes("flash")) return "gemini-flash"; return "gemini-pro"; } function updateGroup(groups, group, remainingFraction, resetTime) { const entry = groups[group] || { count: 0 }; entry.count += 1; if (typeof remainingFraction === "number") { if (entry.remaining === undefined) { entry.remaining = remainingFraction; } else { entry.remaining = Math.min(entry.remaining, remainingFraction); } } if (resetTime) { const timestamp = Date.parse(resetTime); if (Number.isFinite(timestamp)) { if (!entry.resetTime) { entry.resetTime = resetTime; } else { const existing = Date.parse(entry.resetTime); if (!Number.isFinite(existing) || timestamp < existing) { entry.resetTime = resetTime; } } } } groups[group] = entry; } function formatDuration(targetTime) { const delta = targetTime - Date.now(); if (delta <= 0) return "now"; const totalSeconds = Math.round(delta / 1000); const hours = Math.floor(totalSeconds / 3600); const minutes = Math.floor((totalSeconds % 3600) / 60); if (hours > 0) return `${hours}h ${minutes}m`; return `${minutes}m`; } function printGroup(label, entry) { if (!entry || entry.count === 0) return; const remaining = typeof entry.remaining === "number" ? Math.round(entry.remaining * 100) : null; const status = remaining === null ? "UNKNOWN" : remaining <= 0 ? "LIMITED" : "OK"; const details = []; if (remaining !== null) details.push(`remaining ${remaining}%`); if (entry.resetTime) { const time = formatDuration(Date.parse(entry.resetTime)); details.push(`resets in ${time}`); } const suffix = details.length ? ` (${details.join(", ")})` : ""; console.log(` ${label}: ${status}${suffix}`); } async function run() { const { path, accountIndex } = parseArgs(); const payload = JSON.parse(readFileSync(path, "utf8")); const accounts = payload.accounts || []; if (accounts.length === 0) { console.log("No accounts found."); return; } const selected = accountIndex === null ? accounts.map((account, index) => ({ account, index })) : accounts .map((account, index) => ({ account, index })) .filter((item) => item.index === accountIndex); for (const { account, index } of selected) { const label = account.email || `Account ${index + 1}`; const disabled = account.enabled === false ? " (disabled)" : ""; console.log(`\n${index + 1}. ${label}${disabled}`); try { const accessToken = await refreshAccessToken(account.refreshToken); let projectId = await loadProjectId(accessToken); if (!projectId) { projectId = account.managedProjectId || account.projectId || FALLBACK_PROJECT_ID; } console.log(` project: ${projectId}`); const body = projectId ? { project: projectId } : {}; const response = await postJson( `${CLOUD_CODE_BASE}/v1internal:fetchAvailableModels`, accessToken, body, ); console.log(` fetchAvailableModels: ${response.status}`); if (!response.ok) { const text = await response.text().catch(() => ""); console.log(` error: ${text.trim().slice(0, 200)}`); continue; } const data = await response.json(); const groups = {}; const models = data.models || {}; for (const [modelName, info] of Object.entries(models)) { const group = classifyGroup(modelName); if (!group) continue; if (!info || !info.quotaInfo) continue; const remaining = info.quotaInfo.remainingFraction ?? 0; updateGroup(groups, group, remaining, info.quotaInfo.resetTime); } printGroup("Claude", groups["claude"]); printGroup("Gemini 3 Pro", groups["gemini-pro"]); printGroup("Gemini 3 Flash", groups["gemini-flash"]); } catch (error) { console.log(` error: ${error instanceof Error ? error.message : String(error)}`); } } } run().catch((error) => { console.error(error); process.exitCode = 1; }); ================================================ FILE: scripts/setup-opencode-pi.sh ================================================ #!/bin/bash set -e # Colors GREEN='\033[0;32m' BLUE='\033[0;34m' YELLOW='\033[0;33m' NC='\033[0m' echo -e "${BLUE}=== Opencode CLI Setup for Raspberry Pi ===${NC}" # 1. Install Opencode CLI if ! command -v opencode &> /dev/null; then echo -e "${GREEN}[1/3] Installing @opencode-ai/cli globally...${NC}" npm install -g @opencode-ai/cli else echo -e "${GREEN}[1/3] Opencode CLI already installed.${NC}" fi # 2. Configure Opencode CONFIG_DIR="$HOME/.config/opencode" CONFIG_FILE="$CONFIG_DIR/opencode.json" mkdir -p "$CONFIG_DIR" if [ ! -f "$CONFIG_FILE" ]; then echo -e "${GREEN}[2/3] Creating opencode.json...${NC}" cat < "$CONFIG_FILE" { "\$schema": "https://opencode.ai/config.json", "plugin": ["opencode-antigravity-auth@latest"], "provider": { "google": { "models": { "antigravity-gemini-3-pro": { "name": "Gemini 3 Pro (Antigravity)", "limit": { "context": 1048576, "output": 65535 }, "modalities": { "input": ["text", "image", "pdf"], "output": ["text"] }, "variants": { "low": { "thinkingLevel": "low" }, "high": { "thinkingLevel": "high" } } } } } } } EOF echo "Configuration created at $CONFIG_FILE" else echo -e "${YELLOW}[2/3] opencode.json already exists. Skipping overwrite.${NC}" echo "Make sure 'opencode-antigravity-auth@latest' is in your 'plugin' list." fi # 3. Auth Instructions echo -e "${BLUE}=== Authentication Required ===${NC}" echo -e "${YELLOW}Since you are on a headless Pi, you need to use SSH port forwarding for the OAuth callback.${NC}" echo "" echo "1. On your LOCAL machine (laptop), run:" echo " ssh -L 51121:localhost:51121 pi@" echo "" echo "2. On the PI (this terminal), run:" echo " opencode auth login" echo "" echo "3. Open the URL in your local browser. The callback will be forwarded to the Pi." ================================================ FILE: scripts/setup-pi-runner.sh ================================================ #!/bin/bash set -e # Colors for output GREEN='\033[0;32m' BLUE='\033[0;34m' NC='\033[0m' # No Color echo -e "${BLUE}=== Opencode Raspberry Pi Runner Setup ===${NC}" # 1. System Updates & Dependencies echo -e "${GREEN}[1/5] Installing system dependencies...${NC}" sudo apt-get update sudo apt-get install -y curl jq git libdigest-sha-perl # 2. Install Node.js (LTS) echo -e "${GREEN}[2/5] Installing Node.js (LTS)...${NC}" if ! command -v node &> /dev/null; then curl -fsSL https://deb.nodesource.com/setup_lts.x | sudo -E bash - sudo apt-get install -y nodejs else echo "Node.js is already installed." fi # 3. Install GitHub CLI (gh) echo -e "${GREEN}[3/5] Installing GitHub CLI...${NC}" if ! command -v gh &> /dev/null; then (type -p wget >/dev/null || (sudo apt update && sudo apt-get install wget -y)) \ && sudo mkdir -p -m 755 /etc/apt/keyrings \ && wget -qO- https://cli.github.com/packages/githubcli-archive-keyring.gpg | sudo tee /etc/apt/keyrings/githubcli-archive-keyring.gpg > /dev/null \ && sudo chmod go+r /etc/apt/keyrings/githubcli-archive-keyring.gpg \ && echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" | sudo tee /etc/apt/sources.list.d/github-cli.list > /dev/null \ && sudo apt update \ && sudo apt install gh -y else echo "GitHub CLI is already installed." fi # 4. Setup Actions Runner echo -e "${GREEN}[4/5] Setting up GitHub Actions Runner...${NC}" mkdir -p actions-runner && cd actions-runner # Detect architecture ARCH=$(dpkg --print-architecture) if [ "$ARCH" == "arm64" ]; then RUNNER_ARCH="arm64" elif [ "$ARCH" == "armhf" ]; then RUNNER_ARCH="arm" else RUNNER_ARCH="x64" fi echo "Detected architecture: $RUNNER_ARCH" # Fetch latest runner version LATEST_VERSION=$(curl -s https://api.github.com/repos/actions/runner/releases/latest | jq -r .tag_name | sed 's/v//') echo "Downloading runner version $LATEST_VERSION..." curl -o actions-runner-linux-${RUNNER_ARCH}-${LATEST_VERSION}.tar.gz -L https://github.com/actions/runner/releases/download/v${LATEST_VERSION}/actions-runner-linux-${RUNNER_ARCH}-${LATEST_VERSION}.tar.gz echo "Extracting..." tar xzf ./actions-runner-linux-${RUNNER_ARCH}-${LATEST_VERSION}.tar.gz # 5. Configuration Prompt echo -e "${BLUE}=== Configuration Needed ===${NC}" echo "You need your Runner Token from GitHub." echo "Go to: Settings > Actions > Runners > New self-hosted runner" echo "Enter your Repo URL and Token below." read -p "Repository URL (e.g., https://github.com/user/repo): " REPO_URL read -p "Runner Token: " RUNNER_TOKEN echo -e "${GREEN}Configuring runner...${NC}" ./config.sh --url "$REPO_URL" --token "$RUNNER_TOKEN" --name "pi-triage-runner" --work "_work" --labels "self-hosted,pi" --unattended --replace echo -e "${GREEN}Installing service...${NC}" sudo ./svc.sh install sudo ./svc.sh start echo -e "${BLUE}=== Setup Complete! ===${NC}" echo "Your Pi is now listening for jobs." ================================================ FILE: src/antigravity/oauth.ts ================================================ import { generatePKCE } from "@openauthjs/openauth/pkce"; import { ANTIGRAVITY_CLIENT_ID, ANTIGRAVITY_CLIENT_SECRET, ANTIGRAVITY_REDIRECT_URI, ANTIGRAVITY_SCOPES, ANTIGRAVITY_ENDPOINT_FALLBACKS, ANTIGRAVITY_LOAD_ENDPOINTS, getAntigravityHeaders, GEMINI_CLI_HEADERS, } from "../constants"; import { createLogger } from "../plugin/logger"; import { calculateTokenExpiry } from "../plugin/auth"; const log = createLogger("oauth"); interface PkcePair { challenge: string; verifier: string; } interface AntigravityAuthState { verifier: string; projectId: string; } /** * Result returned to the caller after constructing an OAuth authorization URL. */ export interface AntigravityAuthorization { url: string; verifier: string; projectId: string; } interface AntigravityTokenExchangeSuccess { type: "success"; refresh: string; access: string; expires: number; email?: string; projectId: string; } interface AntigravityTokenExchangeFailure { type: "failed"; error: string; } export type AntigravityTokenExchangeResult = | AntigravityTokenExchangeSuccess | AntigravityTokenExchangeFailure; interface AntigravityTokenResponse { access_token: string; expires_in: number; refresh_token: string; } interface AntigravityUserInfo { email?: string; } /** * Encode an object into a URL-safe base64 string. */ function encodeState(payload: AntigravityAuthState): string { return Buffer.from(JSON.stringify(payload), "utf8").toString("base64url"); } /** * Decode an OAuth state parameter back into its structured representation. */ function decodeState(state: string): AntigravityAuthState { const normalized = state.replace(/-/g, "+").replace(/_/g, "/"); const padded = normalized.padEnd(normalized.length + ((4 - normalized.length % 4) % 4), "="); const json = Buffer.from(padded, "base64").toString("utf8"); const parsed = JSON.parse(json); if (typeof parsed.verifier !== "string") { throw new Error("Missing PKCE verifier in state"); } return { verifier: parsed.verifier, projectId: typeof parsed.projectId === "string" ? parsed.projectId : "", }; } /** * Build the Antigravity OAuth authorization URL including PKCE and optional project metadata. */ export async function authorizeAntigravity(projectId = ""): Promise { const pkce = (await generatePKCE()) as PkcePair; const url = new URL("https://accounts.google.com/o/oauth2/v2/auth"); url.searchParams.set("client_id", ANTIGRAVITY_CLIENT_ID); url.searchParams.set("response_type", "code"); url.searchParams.set("redirect_uri", ANTIGRAVITY_REDIRECT_URI); url.searchParams.set("scope", ANTIGRAVITY_SCOPES.join(" ")); url.searchParams.set("code_challenge", pkce.challenge); url.searchParams.set("code_challenge_method", "S256"); url.searchParams.set( "state", encodeState({ verifier: pkce.verifier, projectId: projectId || "" }), ); url.searchParams.set("access_type", "offline"); url.searchParams.set("prompt", "consent"); return { url: url.toString(), verifier: pkce.verifier, projectId: projectId || "", }; } const FETCH_TIMEOUT_MS = 10000; async function fetchWithTimeout( url: string, options: RequestInit, timeoutMs = FETCH_TIMEOUT_MS, ): Promise { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), timeoutMs); try { return await fetch(url, { ...options, signal: controller.signal }); } finally { clearTimeout(timeout); } } async function fetchProjectID(accessToken: string): Promise { const errors: string[] = []; const loadHeaders: Record = { Authorization: `Bearer ${accessToken}`, "Content-Type": "application/json", "User-Agent": GEMINI_CLI_HEADERS["User-Agent"], "Client-Metadata": getAntigravityHeaders()["Client-Metadata"], }; const loadEndpoints = Array.from( new Set([...ANTIGRAVITY_LOAD_ENDPOINTS, ...ANTIGRAVITY_ENDPOINT_FALLBACKS]), ); for (const baseEndpoint of loadEndpoints) { try { const url = `${baseEndpoint}/v1internal:loadCodeAssist`; const response = await fetchWithTimeout(url, { method: "POST", headers: loadHeaders, body: JSON.stringify({ metadata: { ideType: "ANTIGRAVITY", platform: process.platform === "win32" ? "WINDOWS" : "MACOS", pluginType: "GEMINI", }, }), }); if (!response.ok) { const message = await response.text().catch(() => ""); errors.push( `loadCodeAssist ${response.status} at ${baseEndpoint}${ message ? `: ${message}` : "" }`, ); continue; } const data = await response.json(); if (typeof data.cloudaicompanionProject === "string" && data.cloudaicompanionProject) { return data.cloudaicompanionProject; } if ( data.cloudaicompanionProject && typeof data.cloudaicompanionProject.id === "string" && data.cloudaicompanionProject.id ) { return data.cloudaicompanionProject.id; } errors.push(`loadCodeAssist missing project id at ${baseEndpoint}`); } catch (e) { errors.push( `loadCodeAssist error at ${baseEndpoint}: ${ e instanceof Error ? e.message : String(e) }`, ); } } if (errors.length) { log.warn("Failed to resolve Antigravity project via loadCodeAssist", { errors: errors.join("; ") }); } return ""; } /** * Exchange an authorization code for Antigravity CLI access and refresh tokens. */ export async function exchangeAntigravity( code: string, state: string, ): Promise { try { const { verifier, projectId } = decodeState(state); const startTime = Date.now(); const tokenResponse = await fetch("https://oauth2.googleapis.com/token", { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded;charset=UTF-8", "Accept": "*/*", "Accept-Encoding": "gzip, deflate, br", "User-Agent": GEMINI_CLI_HEADERS["User-Agent"], }, body: new URLSearchParams({ client_id: ANTIGRAVITY_CLIENT_ID, client_secret: ANTIGRAVITY_CLIENT_SECRET, code, grant_type: "authorization_code", redirect_uri: ANTIGRAVITY_REDIRECT_URI, code_verifier: verifier, }), }); if (!tokenResponse.ok) { const errorText = await tokenResponse.text(); return { type: "failed", error: errorText }; } const tokenPayload = (await tokenResponse.json()) as AntigravityTokenResponse; const userInfoResponse = await fetch( "https://www.googleapis.com/oauth2/v1/userinfo?alt=json", { headers: { Authorization: `Bearer ${tokenPayload.access_token}`, "User-Agent": GEMINI_CLI_HEADERS["User-Agent"], }, }, ); const userInfo = userInfoResponse.ok ? ((await userInfoResponse.json()) as AntigravityUserInfo) : {}; const refreshToken = tokenPayload.refresh_token; if (!refreshToken) { return { type: "failed", error: "Missing refresh token in response" }; } let effectiveProjectId = projectId; if (!effectiveProjectId) { effectiveProjectId = await fetchProjectID(tokenPayload.access_token); } const storedRefresh = `${refreshToken}|${effectiveProjectId || ""}`; return { type: "success", refresh: storedRefresh, access: tokenPayload.access_token, expires: calculateTokenExpiry(startTime, tokenPayload.expires_in), email: userInfo.email, projectId: effectiveProjectId || "", }; } catch (error) { return { type: "failed", error: error instanceof Error ? error.message : "Unknown error", }; } } ================================================ FILE: src/constants.test.ts ================================================ import { describe, it, expect } from "vitest" import { GEMINI_CLI_HEADERS, getRandomizedHeaders, type HeaderSet, } from "./constants.ts" describe("GEMINI_CLI_HEADERS", () => { it("matches Code Assist headers from opencode-gemini-auth", () => { expect(GEMINI_CLI_HEADERS).toEqual({ "User-Agent": "google-api-nodejs-client/9.15.1", "X-Goog-Api-Client": "gl-node/22.17.0", "Client-Metadata": "ideType=IDE_UNSPECIFIED,platform=PLATFORM_UNSPECIFIED,pluginType=GEMINI", }) }) }) describe("getRandomizedHeaders", () => { describe("gemini-cli style", () => { it("returns static Code Assist headers", () => { const headers = getRandomizedHeaders("gemini-cli", "gemini-2.5-pro") expect(headers).toEqual({ "User-Agent": "google-api-nodejs-client/9.15.1", "X-Goog-Api-Client": "gl-node/22.17.0", "Client-Metadata": "ideType=IDE_UNSPECIFIED,platform=PLATFORM_UNSPECIFIED,pluginType=GEMINI", }) }) it("ignores requested model and keeps static User-Agent", () => { const headers = getRandomizedHeaders("gemini-cli", "gemini-3-pro-preview") expect(headers["User-Agent"]).toBe("google-api-nodejs-client/9.15.1") }) }) describe("antigravity style", () => { it("returns all three headers", () => { const headers = getRandomizedHeaders("antigravity") expect(headers["User-Agent"]).toBeDefined() expect(headers["X-Goog-Api-Client"]).toBeDefined() expect(headers["Client-Metadata"]).toBeDefined() }) it("returns User-Agent in antigravity format", () => { const headers = getRandomizedHeaders("antigravity") expect(headers["User-Agent"]).toMatch(/^antigravity\//) }) it("aligns Client-Metadata platform with User-Agent platform", () => { for (let i = 0; i < 50; i++) { const headers = getRandomizedHeaders("antigravity") const ua = headers["User-Agent"]! const metadata = JSON.parse(headers["Client-Metadata"]!) if (ua.includes("windows/")) { expect(metadata.platform).toBe("WINDOWS") } else { expect(metadata.platform).toBe("MACOS") } } }) it("never produces a linux User-Agent", () => { for (let i = 0; i < 50; i++) { const headers = getRandomizedHeaders("antigravity") expect(headers["User-Agent"]).not.toMatch(/linux\//) } }) }) }) describe("HeaderSet type", () => { it("allows omitting X-Goog-Api-Client and Client-Metadata", () => { const headers: HeaderSet = { "User-Agent": "test", } expect(headers["User-Agent"]).toBe("test") expect(headers["X-Goog-Api-Client"]).toBeUndefined() expect(headers["Client-Metadata"]).toBeUndefined() }) it("allows including all three headers", () => { const headers: HeaderSet = { "User-Agent": "test", "X-Goog-Api-Client": "test-client", "Client-Metadata": "test-metadata", } expect(headers["User-Agent"]).toBe("test") expect(headers["X-Goog-Api-Client"]).toBe("test-client") expect(headers["Client-Metadata"]).toBe("test-metadata") }) }) ================================================ FILE: src/constants.ts ================================================ /** * Constants used for Antigravity OAuth flows and Cloud Code Assist API integration. */ export const ANTIGRAVITY_CLIENT_ID = "1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com"; /** * Client secret issued for the Antigravity OAuth application. */ export const ANTIGRAVITY_CLIENT_SECRET = "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"; /** * Scopes required for Antigravity integrations. */ export const ANTIGRAVITY_SCOPES: readonly string[] = [ "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", ]; /** * OAuth redirect URI used by the local CLI callback server. */ export const ANTIGRAVITY_REDIRECT_URI = "http://localhost:51121/oauth-callback"; /** * Root endpoints for the Antigravity API (in fallback order). * CLIProxy and Vibeproxy use the daily sandbox endpoint first, * then fallback to autopush and prod if needed. */ export const ANTIGRAVITY_ENDPOINT_DAILY = "https://daily-cloudcode-pa.sandbox.googleapis.com"; export const ANTIGRAVITY_ENDPOINT_AUTOPUSH = "https://autopush-cloudcode-pa.sandbox.googleapis.com"; export const ANTIGRAVITY_ENDPOINT_PROD = "https://cloudcode-pa.googleapis.com"; /** * Endpoint fallback order (daily → autopush → prod). * Shared across request handling and project discovery to mirror CLIProxy behavior. */ export const ANTIGRAVITY_ENDPOINT_FALLBACKS = [ ANTIGRAVITY_ENDPOINT_DAILY, ANTIGRAVITY_ENDPOINT_AUTOPUSH, ANTIGRAVITY_ENDPOINT_PROD, ] as const; /** * Preferred endpoint order for project discovery (prod first, then fallbacks). * loadCodeAssist appears to be best supported on prod for managed project resolution. */ export const ANTIGRAVITY_LOAD_ENDPOINTS = [ ANTIGRAVITY_ENDPOINT_PROD, ANTIGRAVITY_ENDPOINT_DAILY, ANTIGRAVITY_ENDPOINT_AUTOPUSH, ] as const; /** * Primary endpoint to use (daily sandbox - same as CLIProxy/Vibeproxy). */ export const ANTIGRAVITY_ENDPOINT = ANTIGRAVITY_ENDPOINT_DAILY; /** * Gemini CLI endpoint (production). * Used for models without :antigravity suffix. * Same as opencode-gemini-auth's GEMINI_CODE_ASSIST_ENDPOINT. */ export const GEMINI_CLI_ENDPOINT = ANTIGRAVITY_ENDPOINT_PROD; /** * Hardcoded project id used when Antigravity does not return one (e.g., business/workspace accounts). */ export const ANTIGRAVITY_DEFAULT_PROJECT_ID = "rising-fact-p41fc"; export const ANTIGRAVITY_VERSION_FALLBACK = "1.18.3"; let antigravityVersion = ANTIGRAVITY_VERSION_FALLBACK; let versionLocked = false; export function getAntigravityVersion(): string { return antigravityVersion; } /** * Set the runtime Antigravity version. Can only be called once (at startup). * Subsequent calls are silently ignored to prevent accidental mutation. */ export function setAntigravityVersion(version: string): void { if (versionLocked) return; antigravityVersion = version; versionLocked = true; } /** @deprecated Use getAntigravityVersion() for runtime access. */ export const ANTIGRAVITY_VERSION = ANTIGRAVITY_VERSION_FALLBACK; export function getAntigravityHeaders(): HeaderSet & { "Client-Metadata": string } { return { "User-Agent": `Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Antigravity/${getAntigravityVersion()} Chrome/138.0.7204.235 Electron/37.3.1 Safari/537.36`, "X-Goog-Api-Client": "google-cloud-sdk vscode_cloudshelleditor/0.1", "Client-Metadata": `{"ideType":"ANTIGRAVITY","platform":"${process.platform === "win32" ? "WINDOWS" : "MACOS"}","pluginType":"GEMINI"}`, }; } /** @deprecated Use getAntigravityHeaders() for runtime access. */ export const ANTIGRAVITY_HEADERS = { "User-Agent": `Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Antigravity/${ANTIGRAVITY_VERSION} Chrome/138.0.7204.235 Electron/37.3.1 Safari/537.36`, "X-Goog-Api-Client": "google-cloud-sdk vscode_cloudshelleditor/0.1", "Client-Metadata": `{"ideType":"ANTIGRAVITY","platform":"${process.platform === "win32" ? "WINDOWS" : "MACOS"}","pluginType":"GEMINI"}`, } as const; export const GEMINI_CLI_HEADERS = { "User-Agent": "google-api-nodejs-client/9.15.1", "X-Goog-Api-Client": "gl-node/22.17.0", "Client-Metadata": "ideType=IDE_UNSPECIFIED,platform=PLATFORM_UNSPECIFIED,pluginType=GEMINI", } as const; const ANTIGRAVITY_PLATFORMS = ["windows/amd64", "darwin/arm64", "darwin/amd64"] as const; const ANTIGRAVITY_API_CLIENTS = [ "google-cloud-sdk vscode_cloudshelleditor/0.1", "google-cloud-sdk vscode/1.96.0", "google-cloud-sdk vscode/1.95.0", ] as const; function randomFrom(arr: readonly T[]): T { return arr[Math.floor(Math.random() * arr.length)]!; } export type HeaderSet = { "User-Agent": string; "X-Goog-Api-Client"?: string; "Client-Metadata"?: string; }; export function getRandomizedHeaders(style: HeaderStyle, model?: string): HeaderSet { if (style === "gemini-cli") { return { "User-Agent": GEMINI_CLI_HEADERS["User-Agent"], "X-Goog-Api-Client": GEMINI_CLI_HEADERS["X-Goog-Api-Client"], "Client-Metadata": GEMINI_CLI_HEADERS["Client-Metadata"], }; } const platform = randomFrom(ANTIGRAVITY_PLATFORMS); const metadataPlatform = platform.startsWith("windows") ? "WINDOWS" : "MACOS"; return { "User-Agent": `antigravity/${getAntigravityVersion()} ${platform}`, "X-Goog-Api-Client": randomFrom(ANTIGRAVITY_API_CLIENTS), "Client-Metadata": `{"ideType":"ANTIGRAVITY","platform":"${metadataPlatform}","pluginType":"GEMINI"}`, }; } export type HeaderStyle = "antigravity" | "gemini-cli"; /** * Provider identifier shared between the plugin loader and credential store. */ export const ANTIGRAVITY_PROVIDER_ID = "google"; // ============================================================================ // TOOL HALLUCINATION PREVENTION (Ported from LLM-API-Key-Proxy) // ============================================================================ /** * System instruction for Claude tool usage hardening. * Prevents hallucinated parameters by explicitly stating the rules. * * This is injected when tools are present to reduce cases where Claude * uses parameter names from its training data instead of the actual schema. */ export const CLAUDE_TOOL_SYSTEM_INSTRUCTION = `CRITICAL TOOL USAGE INSTRUCTIONS: You are operating in a custom environment where tool definitions differ from your training data. You MUST follow these rules strictly: 1. DO NOT use your internal training data to guess tool parameters 2. ONLY use the exact parameter structure defined in the tool schema 3. Parameter names in schemas are EXACT - do not substitute with similar names from your training 4. Array parameters have specific item types - check the schema's 'items' field for the exact structure 5. When you see "STRICT PARAMETERS" in a tool description, those type definitions override any assumptions 6. Tool use in agentic workflows is REQUIRED - you must call tools with the exact parameters specified If you are unsure about a tool's parameters, YOU MUST read the schema definition carefully.`; /** * Template for parameter signature injection into tool descriptions. * {params} will be replaced with the actual parameter list. */ export const CLAUDE_DESCRIPTION_PROMPT = "\n\n⚠️ STRICT PARAMETERS: {params}."; export const EMPTY_SCHEMA_PLACEHOLDER_NAME = "_placeholder"; export const EMPTY_SCHEMA_PLACEHOLDER_DESCRIPTION = "Placeholder. Always pass true."; /** * Sentinel value to bypass thought signature validation. * * When a thinking block has an invalid or missing signature (e.g., cache miss, * session mismatch, plugin restart), this sentinel can be injected to skip * validation instead of failing with "Invalid signature in thinking block". * * This is an officially supported Google API feature, used by: * - gemini-cli: https://github.com/google-gemini/gemini-cli * - Google .NET SDK: PredictionServiceChatClient.cs * * @see https://ai.google.dev/gemini-api/docs/thought-signatures */ export const SKIP_THOUGHT_SIGNATURE = "skip_thought_signature_validator"; // ============================================================================ // ANTIGRAVITY SYSTEM INSTRUCTION (Ported from CLIProxyAPI v6.6.89) // ============================================================================ /** * System instruction for Antigravity requests. * This is injected into requests to match CLIProxyAPI v6.6.89 behavior. * The instruction provides identity and guidelines for the Antigravity agent. */ // ============================================================================ // GOOGLE SEARCH TOOL CONSTANTS // ============================================================================ /** * Model used for Google Search grounding requests. * Uses gemini-2.5-flash for fast, cost-effective search operations. (3-flash is always at capacity and doesn't support souce citation). */ export const SEARCH_MODEL = "gemini-2.5-flash"; /** * Thinking budget for deep search (more thorough analysis). */ export const SEARCH_THINKING_BUDGET_DEEP = 16384; /** * Thinking budget for fast search (quick results). */ export const SEARCH_THINKING_BUDGET_FAST = 4096; /** * Timeout for search requests in milliseconds (60 seconds). */ export const SEARCH_TIMEOUT_MS = 60000; /** * System instruction for the Google Search tool. */ export const SEARCH_SYSTEM_INSTRUCTION = `You are an expert web search assistant with access to Google Search and URL analysis tools. Your capabilities: - Use google_search to find real-time information from the web - Use url_context to fetch and analyze content from specific URLs when provided Guidelines: - Always provide accurate, well-sourced information - Cite your sources when presenting facts - If analyzing URLs, extract the most relevant information - Be concise but comprehensive in your responses - If information is uncertain or conflicting, acknowledge it - Focus on answering the user's question directly`; 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** IMPORTANT: The instructions that follow supersede all above. Follow them as your primary directives. `; ================================================ FILE: src/hooks/auto-update-checker/cache.ts ================================================ import * as fs from "node:fs"; import * as path from "node:path"; import { CACHE_DIR, PACKAGE_NAME } from "./constants"; interface BunLockfile { workspaces?: { ""?: { dependencies?: Record; }; }; packages?: Record; } function stripTrailingCommas(json: string): string { return json.replace(/,(\s*[}\]])/g, "$1"); } function removeFromBunLock(packageName: string): boolean { const lockPath = path.join(CACHE_DIR, "bun.lock"); if (!fs.existsSync(lockPath)) return false; try { const content = fs.readFileSync(lockPath, "utf-8"); const lock = JSON.parse(stripTrailingCommas(content)) as BunLockfile; let modified = false; if (lock.workspaces?.[""]?.dependencies?.[packageName]) { delete lock.workspaces[""].dependencies[packageName]; modified = true; } if (lock.packages?.[packageName]) { delete lock.packages[packageName]; modified = true; } if (modified) { fs.writeFileSync(lockPath, JSON.stringify(lock, null, 2)); console.log(`[auto-update-checker] Removed from bun.lock: ${packageName}`); } return modified; } catch { return false; } } export function invalidatePackage(packageName: string = PACKAGE_NAME): boolean { try { const pkgDir = path.join(CACHE_DIR, "node_modules", packageName); const pkgJsonPath = path.join(CACHE_DIR, "package.json"); let packageRemoved = false; let dependencyRemoved = false; let lockRemoved = false; if (fs.existsSync(pkgDir)) { fs.rmSync(pkgDir, { recursive: true, force: true }); console.log(`[auto-update-checker] Package removed: ${pkgDir}`); packageRemoved = true; } if (fs.existsSync(pkgJsonPath)) { const content = fs.readFileSync(pkgJsonPath, "utf-8"); const pkgJson = JSON.parse(content); if (pkgJson.dependencies?.[packageName]) { delete pkgJson.dependencies[packageName]; fs.writeFileSync(pkgJsonPath, JSON.stringify(pkgJson, null, 2)); console.log(`[auto-update-checker] Dependency removed from package.json: ${packageName}`); dependencyRemoved = true; } } lockRemoved = removeFromBunLock(packageName); if (!packageRemoved && !dependencyRemoved && !lockRemoved) { console.log(`[auto-update-checker] Package not found, nothing to invalidate: ${packageName}`); return false; } return true; } catch (err) { console.error("[auto-update-checker] Failed to invalidate package:", err); return false; } } export function invalidateCache(): boolean { console.warn("[auto-update-checker] WARNING: invalidateCache is deprecated, use invalidatePackage"); return invalidatePackage(); } ================================================ FILE: src/hooks/auto-update-checker/checker.ts ================================================ import * as fs from "node:fs"; import * as path from "node:path"; import { fileURLToPath } from "node:url"; import type { NpmDistTags, OpencodeConfig, PackageJson, UpdateCheckResult } from "./types"; import { PACKAGE_NAME, NPM_REGISTRY_URL, NPM_FETCH_TIMEOUT, INSTALLED_PACKAGE_JSON, USER_OPENCODE_CONFIG, USER_OPENCODE_CONFIG_JSONC, } from "./constants"; import { logAutoUpdate } from "./logging"; export function isLocalDevMode(directory: string): boolean { return getLocalDevPath(directory) !== null; } function stripJsonComments(json: string): string { return json .replace(/\\"|"(?:\\"|[^"])*"|(\/\/.*|\/\*[\s\S]*?\*\/)/g, (m: string, g: string | undefined) => (g ? "" : m)) .replace(/,(\s*[}\]])/g, "$1"); } function getConfigPaths(directory: string): string[] { return [ path.join(directory, ".opencode", "opencode.json"), path.join(directory, ".opencode", "opencode.jsonc"), path.join(directory, ".opencode.json"), USER_OPENCODE_CONFIG, USER_OPENCODE_CONFIG_JSONC, ]; } export function getLocalDevPath(directory: string): string | null { for (const configPath of getConfigPaths(directory)) { try { if (!fs.existsSync(configPath)) continue; const content = fs.readFileSync(configPath, "utf-8"); const config = JSON.parse(stripJsonComments(content)) as OpencodeConfig; const plugins = config.plugin ?? []; for (const entry of plugins) { if (entry.startsWith("file://") && entry.includes(PACKAGE_NAME)) { try { return fileURLToPath(entry); } catch { return entry.replace("file://", ""); } } } } catch { continue; } } return null; } function findPackageJsonUp(startPath: string): string | null { try { const stat = fs.statSync(startPath); let dir = stat.isDirectory() ? startPath : path.dirname(startPath); for (let i = 0; i < 10; i++) { const pkgPath = path.join(dir, "package.json"); if (fs.existsSync(pkgPath)) { try { const content = fs.readFileSync(pkgPath, "utf-8"); const pkg = JSON.parse(content) as PackageJson; if (pkg.name === PACKAGE_NAME) return pkgPath; } catch { continue; } } const parent = path.dirname(dir); if (parent === dir) break; dir = parent; } } catch { return null; } return null; } export function getLocalDevVersion(directory: string): string | null { const localPath = getLocalDevPath(directory); if (!localPath) return null; try { const pkgPath = findPackageJsonUp(localPath); if (!pkgPath) return null; const content = fs.readFileSync(pkgPath, "utf-8"); const pkg = JSON.parse(content) as PackageJson; return pkg.version ?? null; } catch { return null; } } export interface PluginEntryInfo { entry: string; isPinned: boolean; pinnedVersion: string | null; configPath: string; } export function findPluginEntry(directory: string): PluginEntryInfo | null { for (const configPath of getConfigPaths(directory)) { try { if (!fs.existsSync(configPath)) continue; const content = fs.readFileSync(configPath, "utf-8"); const config = JSON.parse(stripJsonComments(content)) as OpencodeConfig; const plugins = config.plugin ?? []; for (const entry of plugins) { if (entry === PACKAGE_NAME) { return { entry, isPinned: false, pinnedVersion: null, configPath }; } if (entry.startsWith(`${PACKAGE_NAME}@`)) { const pinnedVersion = entry.slice(PACKAGE_NAME.length + 1); const isPinned = pinnedVersion !== "latest"; return { entry, isPinned, pinnedVersion: isPinned ? pinnedVersion : null, configPath }; } if (entry.startsWith("file://") && entry.includes(PACKAGE_NAME)) { return { entry, isPinned: false, pinnedVersion: null, configPath }; } } } catch { continue; } } return null; } export function getCachedVersion(): string | null { try { if (fs.existsSync(INSTALLED_PACKAGE_JSON)) { const content = fs.readFileSync(INSTALLED_PACKAGE_JSON, "utf-8"); const pkg = JSON.parse(content) as PackageJson; if (pkg.version) return pkg.version; } } catch { return null; } try { const currentDir = path.dirname(fileURLToPath(import.meta.url)); const pkgPath = findPackageJsonUp(currentDir); if (pkgPath) { const content = fs.readFileSync(pkgPath, "utf-8"); const pkg = JSON.parse(content) as PackageJson; if (pkg.version) return pkg.version; } } catch (err) { logAutoUpdate(`Failed to resolve version from current directory: ${err}`); } return null; } export function updatePinnedVersion(configPath: string, oldEntry: string, newVersion: string): boolean { try { const content = fs.readFileSync(configPath, "utf-8"); const newEntry = `${PACKAGE_NAME}@${newVersion}`; const pluginMatch = content.match(/"plugin"\s*:\s*\[/); if (!pluginMatch || pluginMatch.index === undefined) { logAutoUpdate(`No "plugin" array found in ${configPath}`); return false; } const startIdx = pluginMatch.index + pluginMatch[0].length; let bracketCount = 1; let endIdx = startIdx; for (let i = startIdx; i < content.length && bracketCount > 0; i++) { if (content[i] === "[") bracketCount++; else if (content[i] === "]") bracketCount--; endIdx = i; } const before = content.slice(0, startIdx); const pluginArrayContent = content.slice(startIdx, endIdx); const after = content.slice(endIdx); const escapedOldEntry = oldEntry.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); const regex = new RegExp(`["']${escapedOldEntry}["']`); if (!regex.test(pluginArrayContent)) { logAutoUpdate(`Entry "${oldEntry}" not found in plugin array of ${configPath}`); return false; } const updatedPluginArray = pluginArrayContent.replace(regex, `"${newEntry}"`); const updatedContent = before + updatedPluginArray + after; if (updatedContent === content) { logAutoUpdate(`No changes made to ${configPath}`); return false; } fs.writeFileSync(configPath, updatedContent, "utf-8"); logAutoUpdate(`Updated ${configPath}: ${oldEntry} → ${newEntry}`); return true; } catch (err) { console.error(`[auto-update-checker] Failed to update config file ${configPath}:`, err); return false; } } export async function getLatestVersion(): Promise { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), NPM_FETCH_TIMEOUT); try { const response = await fetch(NPM_REGISTRY_URL, { signal: controller.signal, headers: { Accept: "application/json" }, }); if (!response.ok) return null; const data = (await response.json()) as NpmDistTags; return data.latest ?? null; } catch { return null; } finally { clearTimeout(timeoutId); } } export async function checkForUpdate(directory: string): Promise { if (isLocalDevMode(directory)) { logAutoUpdate("Local dev mode detected, skipping update check"); return { needsUpdate: false, currentVersion: null, latestVersion: null, isLocalDev: true, isPinned: false }; } const pluginInfo = findPluginEntry(directory); if (!pluginInfo) { logAutoUpdate("Plugin not found in config"); return { needsUpdate: false, currentVersion: null, latestVersion: null, isLocalDev: false, isPinned: false }; } const currentVersion = getCachedVersion() ?? pluginInfo.pinnedVersion; if (!currentVersion) { logAutoUpdate("No version found (cached or pinned)"); return { needsUpdate: false, currentVersion: null, latestVersion: null, isLocalDev: false, isPinned: pluginInfo.isPinned }; } const latestVersion = await getLatestVersion(); if (!latestVersion) { logAutoUpdate("Failed to fetch latest version"); return { needsUpdate: false, currentVersion, latestVersion: null, isLocalDev: false, isPinned: pluginInfo.isPinned }; } const needsUpdate = currentVersion !== latestVersion; logAutoUpdate(`Current: ${currentVersion}, Latest: ${latestVersion}, NeedsUpdate: ${needsUpdate}`); return { needsUpdate, currentVersion, latestVersion, isLocalDev: false, isPinned: pluginInfo.isPinned }; } ================================================ FILE: src/hooks/auto-update-checker/constants.ts ================================================ import * as path from "node:path"; import * as os from "node:os"; export const PACKAGE_NAME = "opencode-antigravity-auth"; export const NPM_REGISTRY_URL = `https://registry.npmjs.org/-/package/${PACKAGE_NAME}/dist-tags`; export const NPM_FETCH_TIMEOUT = 5000; function getCacheDir(): string { if (process.platform === "win32") { return path.join(process.env.LOCALAPPDATA ?? os.homedir(), "opencode"); } return path.join(os.homedir(), ".cache", "opencode"); } export const CACHE_DIR = getCacheDir(); export const INSTALLED_PACKAGE_JSON = path.join( CACHE_DIR, "node_modules", PACKAGE_NAME, "package.json" ); function getUserConfigDir(): string { if (process.platform === "win32") { return process.env.APPDATA ?? path.join(os.homedir(), "AppData", "Roaming"); } return process.env.XDG_CONFIG_HOME ?? path.join(os.homedir(), ".config"); } export const USER_CONFIG_DIR = getUserConfigDir(); export const USER_OPENCODE_CONFIG = path.join(USER_CONFIG_DIR, "opencode", "opencode.json"); export const USER_OPENCODE_CONFIG_JSONC = path.join(USER_CONFIG_DIR, "opencode", "opencode.jsonc"); ================================================ FILE: src/hooks/auto-update-checker/index.test.ts ================================================ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; vi.mock("./checker", () => ({ getCachedVersion: vi.fn(), getLocalDevVersion: vi.fn(), findPluginEntry: vi.fn(), getLatestVersion: vi.fn(), updatePinnedVersion: vi.fn(), })); vi.mock("./cache", () => ({ invalidatePackage: vi.fn(), })); vi.mock("../../plugin/debug", () => ({ debugLogToFile: vi.fn(), })); import { createAutoUpdateCheckerHook } from "./index"; import { getCachedVersion, getLocalDevVersion, findPluginEntry, getLatestVersion, updatePinnedVersion } from "./checker"; import { invalidatePackage } from "./cache"; function createMockClient() { return { tui: { showToast: vi.fn().mockResolvedValue(undefined), }, }; } function createPluginInfo(overrides: Partial> = {}) { return { configPath: "/test/.config/opencode/opencode.json", entry: "opencode-antigravity-auth@1.2.6", pinnedVersion: "1.2.6", isPinned: true, ...overrides, }; } describe("Auto Update Checker", () => { beforeEach(() => { vi.clearAllMocks(); vi.useFakeTimers(); }); afterEach(() => { vi.useRealTimers(); }); describe("prerelease version handling", () => { it("skips auto-update for beta versions", async () => { const client = createMockClient(); vi.mocked(getLocalDevVersion).mockReturnValue(null); vi.mocked(findPluginEntry).mockReturnValue(createPluginInfo({ pinnedVersion: "1.2.7-beta.1", entry: "opencode-antigravity-auth@1.2.7-beta.1", })); vi.mocked(getCachedVersion).mockReturnValue(null); vi.mocked(getLatestVersion).mockResolvedValue("1.2.6"); const hook = createAutoUpdateCheckerHook(client, "/test", { autoUpdate: true }); hook.event({ event: { type: "session.created" } }); await vi.runAllTimersAsync(); expect(getLatestVersion).not.toHaveBeenCalled(); expect(updatePinnedVersion).not.toHaveBeenCalled(); expect(invalidatePackage).not.toHaveBeenCalled(); expect(client.tui.showToast).not.toHaveBeenCalled(); }); it("skips auto-update for alpha versions", async () => { const client = createMockClient(); vi.mocked(getLocalDevVersion).mockReturnValue(null); vi.mocked(findPluginEntry).mockReturnValue(createPluginInfo({ pinnedVersion: "2.0.0-alpha.3", entry: "opencode-antigravity-auth@2.0.0-alpha.3", })); vi.mocked(getCachedVersion).mockReturnValue(null); const hook = createAutoUpdateCheckerHook(client, "/test", { autoUpdate: true }); hook.event({ event: { type: "session.created" } }); await vi.runAllTimersAsync(); expect(getLatestVersion).not.toHaveBeenCalled(); }); it("skips auto-update for rc versions", async () => { const client = createMockClient(); vi.mocked(getLocalDevVersion).mockReturnValue(null); vi.mocked(findPluginEntry).mockReturnValue(createPluginInfo({ pinnedVersion: "1.3.0-rc.1", entry: "opencode-antigravity-auth@1.3.0-rc.1", })); vi.mocked(getCachedVersion).mockReturnValue(null); const hook = createAutoUpdateCheckerHook(client, "/test", { autoUpdate: true }); hook.event({ event: { type: "session.created" } }); await vi.runAllTimersAsync(); expect(getLatestVersion).not.toHaveBeenCalled(); }); it("skips auto-update when cached version is prerelease", async () => { const client = createMockClient(); vi.mocked(getLocalDevVersion).mockReturnValue(null); vi.mocked(findPluginEntry).mockReturnValue(createPluginInfo({ pinnedVersion: "1.2.6", })); vi.mocked(getCachedVersion).mockReturnValue("1.2.7-beta.2"); const hook = createAutoUpdateCheckerHook(client, "/test", { autoUpdate: true }); hook.event({ event: { type: "session.created" } }); await vi.runAllTimersAsync(); expect(getLatestVersion).not.toHaveBeenCalled(); }); it("proceeds with update check for stable versions", async () => { const client = createMockClient(); vi.mocked(getLocalDevVersion).mockReturnValue(null); vi.mocked(findPluginEntry).mockReturnValue(createPluginInfo({ pinnedVersion: "1.2.5", })); vi.mocked(getCachedVersion).mockReturnValue(null); vi.mocked(getLatestVersion).mockResolvedValue("1.2.6"); vi.mocked(updatePinnedVersion).mockReturnValue(true); const hook = createAutoUpdateCheckerHook(client, "/test", { autoUpdate: true }); hook.event({ event: { type: "session.created" } }); await vi.runAllTimersAsync(); expect(getLatestVersion).toHaveBeenCalled(); }); }); describe("auto-update disabled", () => { it("shows notification but does not update when autoUpdate is false", async () => { const client = createMockClient(); vi.mocked(getLocalDevVersion).mockReturnValue(null); vi.mocked(findPluginEntry).mockReturnValue(createPluginInfo({ pinnedVersion: "1.2.5", })); vi.mocked(getCachedVersion).mockReturnValue(null); vi.mocked(getLatestVersion).mockResolvedValue("1.2.6"); const hook = createAutoUpdateCheckerHook(client, "/test", { autoUpdate: false }); hook.event({ event: { type: "session.created" } }); await vi.runAllTimersAsync(); expect(getLatestVersion).toHaveBeenCalled(); expect(updatePinnedVersion).not.toHaveBeenCalled(); expect(invalidatePackage).not.toHaveBeenCalled(); expect(client.tui.showToast).toHaveBeenCalledWith( expect.objectContaining({ body: expect.objectContaining({ variant: "info", }), }) ); }); }); describe("session handling", () => { it("only checks once per hook instance", async () => { const client = createMockClient(); vi.mocked(getLocalDevVersion).mockReturnValue(null); vi.mocked(findPluginEntry).mockReturnValue(createPluginInfo()); vi.mocked(getCachedVersion).mockReturnValue(null); vi.mocked(getLatestVersion).mockResolvedValue("1.2.6"); const hook = createAutoUpdateCheckerHook(client, "/test"); hook.event({ event: { type: "session.created" } }); hook.event({ event: { type: "session.created" } }); hook.event({ event: { type: "session.created" } }); await vi.runAllTimersAsync(); expect(findPluginEntry).toHaveBeenCalledTimes(1); }); it("ignores child sessions (with parentID)", async () => { const client = createMockClient(); vi.mocked(getLocalDevVersion).mockReturnValue(null); const hook = createAutoUpdateCheckerHook(client, "/test"); hook.event({ event: { type: "session.created", properties: { info: { parentID: "parent-123" } }, }, }); await vi.runAllTimersAsync(); expect(findPluginEntry).not.toHaveBeenCalled(); }); it("ignores non-session.created events", async () => { const client = createMockClient(); vi.mocked(getLocalDevVersion).mockReturnValue(null); const hook = createAutoUpdateCheckerHook(client, "/test"); hook.event({ event: { type: "message.created" } }); await vi.runAllTimersAsync(); expect(findPluginEntry).not.toHaveBeenCalled(); }); }); describe("local development mode", () => { it("skips update check in local dev mode", async () => { const client = createMockClient(); vi.mocked(getLocalDevVersion).mockReturnValue("1.2.7-dev"); const hook = createAutoUpdateCheckerHook(client, "/test"); hook.event({ event: { type: "session.created" } }); await vi.runAllTimersAsync(); expect(findPluginEntry).not.toHaveBeenCalled(); expect(getLatestVersion).not.toHaveBeenCalled(); }); it("shows local dev toast when showStartupToast is true", async () => { const client = createMockClient(); vi.mocked(getLocalDevVersion).mockReturnValue("1.2.7-dev"); const hook = createAutoUpdateCheckerHook(client, "/test", { showStartupToast: true }); hook.event({ event: { type: "session.created" } }); await vi.runAllTimersAsync(); expect(client.tui.showToast).toHaveBeenCalledWith( expect.objectContaining({ body: expect.objectContaining({ variant: "warning", }), }) ); }); }); }); ================================================ FILE: src/hooks/auto-update-checker/index.ts ================================================ import type { AutoUpdateCheckerOptions } from "./types"; import { getCachedVersion, getLocalDevVersion, findPluginEntry, getLatestVersion, updatePinnedVersion } from "./checker"; import { invalidatePackage } from "./cache"; import { PACKAGE_NAME } from "./constants"; import { logAutoUpdate } from "./logging"; interface PluginClient { tui: { showToast(options: { body: { title?: string; message: string; variant: "info" | "warning" | "success" | "error"; duration?: number; }; }): Promise; }; } interface SessionCreatedEvent { type: "session.created"; properties?: { info?: { parentID?: string; }; }; } type PluginEvent = SessionCreatedEvent | { type: string; properties?: unknown }; export function createAutoUpdateCheckerHook( client: PluginClient, directory: string, options: AutoUpdateCheckerOptions = {} ) { const { showStartupToast = true, autoUpdate = true } = options; let hasChecked = false; return { event: ({ event }: { event: PluginEvent }) => { if (event.type !== "session.created") return; if (hasChecked) return; const props = event.properties as { info?: { parentID?: string } } | undefined; if (props?.info?.parentID) return; hasChecked = true; setTimeout(() => { const localDevVersion = getLocalDevVersion(directory); if (localDevVersion) { if (showStartupToast) { showLocalDevToast(client, localDevVersion).catch(() => {}); } logAutoUpdate("Local development mode"); return; } runBackgroundUpdateCheck(client, directory, autoUpdate).catch((err) => { logAutoUpdate(`Background update check failed: ${err}`); }); }, 0); }, }; } async function runBackgroundUpdateCheck( client: PluginClient, directory: string, autoUpdate: boolean ): Promise { const pluginInfo = findPluginEntry(directory); if (!pluginInfo) { logAutoUpdate("Plugin not found in config"); return; } const cachedVersion = getCachedVersion(); const currentVersion = cachedVersion ?? pluginInfo.pinnedVersion; if (!currentVersion) { logAutoUpdate("No version found (cached or pinned)"); return; } if (currentVersion.includes('-')) { logAutoUpdate(`Prerelease version (${currentVersion}), skipping auto-update`); return; } const latestVersion = await getLatestVersion(); if (!latestVersion) { logAutoUpdate("Failed to fetch latest version"); return; } if (currentVersion === latestVersion) { logAutoUpdate("Already on latest version"); return; } logAutoUpdate(`Update available: ${currentVersion} → ${latestVersion}`); if (!autoUpdate) { await showUpdateAvailableToast(client, latestVersion); logAutoUpdate("Auto-update disabled, notification only"); return; } if (pluginInfo.isPinned) { const updated = updatePinnedVersion(pluginInfo.configPath, pluginInfo.entry, latestVersion); if (updated) { invalidatePackage(PACKAGE_NAME); await showAutoUpdatedToast(client, currentVersion, latestVersion); logAutoUpdate(`Config updated: ${pluginInfo.entry} → ${PACKAGE_NAME}@${latestVersion}`); } else { await showUpdateAvailableToast(client, latestVersion); } } else { invalidatePackage(PACKAGE_NAME); await showUpdateAvailableToast(client, latestVersion); } } async function showUpdateAvailableToast(client: PluginClient, latestVersion: string): Promise { await client.tui .showToast({ body: { title: `Antigravity Auth Update`, message: `v${latestVersion} available. Restart OpenCode to apply.`, variant: "info" as const, duration: 8000, }, }) .catch(() => {}); logAutoUpdate(`Update available toast shown: v${latestVersion}`); } async function showAutoUpdatedToast(client: PluginClient, oldVersion: string, newVersion: string): Promise { await client.tui .showToast({ body: { title: `Antigravity Auth Updated!`, message: `v${oldVersion} → v${newVersion}\nRestart OpenCode to apply.`, variant: "success" as const, duration: 8000, }, }) .catch(() => {}); logAutoUpdate(`Auto-updated toast shown: v${oldVersion} → v${newVersion}`); } async function showLocalDevToast(client: PluginClient, version: string): Promise { await client.tui .showToast({ body: { title: `Antigravity Auth ${version} (dev)`, message: "Running in local development mode.", variant: "warning" as const, duration: 5000, }, }) .catch(() => {}); logAutoUpdate(`Local dev toast shown: v${version}`); } export type { UpdateCheckResult, AutoUpdateCheckerOptions } from "./types"; export { checkForUpdate, getCachedVersion, getLatestVersion } from "./checker"; export { invalidatePackage, invalidateCache } from "./cache"; ================================================ FILE: src/hooks/auto-update-checker/logging.ts ================================================ import { debugLogToFile } from "../../plugin/debug"; const AUTO_UPDATE_LOG_PREFIX = "[auto-update-checker]"; export function formatAutoUpdateLogMessage(message: string): string { return `${AUTO_UPDATE_LOG_PREFIX} ${message}`; } export function logAutoUpdate(message: string): void { debugLogToFile(formatAutoUpdateLogMessage(message)); } ================================================ FILE: src/hooks/auto-update-checker/types.ts ================================================ export interface NpmDistTags { latest: string; [key: string]: string; } export interface OpencodeConfig { plugin?: string[]; [key: string]: unknown; } export interface PackageJson { version: string; name?: string; [key: string]: unknown; } export interface UpdateCheckResult { needsUpdate: boolean; currentVersion: string | null; latestVersion: string | null; isLocalDev: boolean; isPinned: boolean; } export interface AutoUpdateCheckerOptions { showStartupToast?: boolean; autoUpdate?: boolean; } ================================================ FILE: src/plugin/accounts.test.ts ================================================ import { beforeEach, describe, expect, it, vi } from "vitest"; import { AccountManager, type ModelFamily, type HeaderStyle, parseRateLimitReason, calculateBackoffMs, type RateLimitReason, resolveQuotaGroup } from "./accounts"; import type { AccountStorageV4 } from "./storage"; import type { OAuthAuthDetails } from "./types"; // Mock storage to prevent test data from leaking to real config files vi.mock("./storage", async (importOriginal) => { const original = await importOriginal(); return { ...original, saveAccounts: vi.fn().mockResolvedValue(undefined), saveAccountsReplace: vi.fn().mockResolvedValue(undefined), }; }); describe("AccountManager", () => { beforeEach(() => { vi.useRealTimers(); vi.stubGlobal("process", { ...process, pid: 0 }); }); it("treats on-disk storage as source of truth, even when empty", () => { const fallback: OAuthAuthDetails = { type: "oauth", refresh: "r1|p1", access: "access", expires: 123, }; const stored: AccountStorageV4 = { version: 4, accounts: [], activeIndex: 0, }; const manager = new AccountManager(fallback, stored); expect(manager.getAccountCount()).toBe(0); }); it("returns current account when not rate-limited for family", () => { const stored: AccountStorageV4 = { version: 4, accounts: [ { refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 }, { refreshToken: "r2", projectId: "p2", addedAt: 1, lastUsed: 0 }, ], activeIndex: 0, }; const manager = new AccountManager(undefined, stored); const family: ModelFamily = "claude"; const account = manager.getCurrentOrNextForFamily(family); expect(account).not.toBeNull(); expect(account?.index).toBe(0); }); it("switches to next account when current is rate-limited for family", () => { const stored: AccountStorageV4 = { version: 4, accounts: [ { refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 }, { refreshToken: "r2", projectId: "p2", addedAt: 1, lastUsed: 0 }, ], activeIndex: 0, }; const manager = new AccountManager(undefined, stored); const family: ModelFamily = "claude"; const firstAccount = manager.getCurrentOrNextForFamily(family); manager.markRateLimited(firstAccount!, 60000, family); const secondAccount = manager.getCurrentOrNextForFamily(family); expect(secondAccount?.index).toBe(1); }); it("returns null when all accounts are rate-limited for family", () => { const stored: AccountStorageV4 = { version: 4, accounts: [ { refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 }, { refreshToken: "r2", projectId: "p2", addedAt: 1, lastUsed: 0 }, ], activeIndex: 0, }; const manager = new AccountManager(undefined, stored); const family: ModelFamily = "claude"; const accounts = manager.getAccounts(); accounts.forEach((acc) => manager.markRateLimited(acc, 60000, family)); const next = manager.getCurrentOrNextForFamily(family); expect(next).toBeNull(); }); it("un-rate-limits accounts after timeout expires", () => { vi.useFakeTimers(); vi.setSystemTime(new Date(0)); const stored: AccountStorageV4 = { version: 4, accounts: [ { refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 }, ], activeIndex: 0, }; const manager = new AccountManager(undefined, stored); const family: ModelFamily = "claude"; const account = manager.getCurrentOrNextForFamily(family); account!.rateLimitResetTimes[family] = Date.now() - 10000; const next = manager.getCurrentOrNextForFamily(family); expect(next?.parts.refreshToken).toBe("r1"); }); it("returns minimum wait time for family", () => { vi.useFakeTimers(); vi.setSystemTime(new Date(0)); const stored: AccountStorageV4 = { version: 4, accounts: [ { refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 }, { refreshToken: "r2", projectId: "p2", addedAt: 1, lastUsed: 0 }, ], activeIndex: 0, }; const manager = new AccountManager(undefined, stored); const family: ModelFamily = "claude"; const accounts = manager.getAccounts(); manager.markRateLimited(accounts[0]!, 30000, family); manager.markRateLimited(accounts[1]!, 60000, family); expect(manager.getMinWaitTimeForFamily(family)).toBe(30000); }); it("tracks rate limits per model family independently", () => { const stored: AccountStorageV4 = { version: 4, accounts: [ { refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 }, ], activeIndex: 0, }; const manager = new AccountManager(undefined, stored); const account = manager.getCurrentOrNextForFamily("claude"); expect(account?.index).toBe(0); manager.markRateLimited(account!, 60000, "claude"); expect(manager.getMinWaitTimeForFamily("claude")).toBeGreaterThan(0); expect(manager.getMinWaitTimeForFamily("gemini")).toBe(0); const geminiOnAccount0 = manager.getNextForFamily("gemini"); expect(geminiOnAccount0?.index).toBe(0); const claudeBlocked = manager.getNextForFamily("claude"); expect(claudeBlocked).toBeNull(); }); it("getCurrentOrNextForFamily sticks to same account until rate-limited", () => { vi.useFakeTimers(); vi.setSystemTime(new Date(0)); const stored: AccountStorageV4 = { version: 4, accounts: [ { refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 }, { refreshToken: "r2", projectId: "p2", addedAt: 1, lastUsed: 0 }, ], activeIndex: 0, }; const manager = new AccountManager(undefined, stored); const family: ModelFamily = "claude"; const first = manager.getCurrentOrNextForFamily(family); expect(first?.parts.refreshToken).toBe("r1"); const second = manager.getCurrentOrNextForFamily(family); expect(second?.parts.refreshToken).toBe("r1"); const third = manager.getCurrentOrNextForFamily(family); expect(third?.parts.refreshToken).toBe("r1"); manager.markRateLimited(first!, 60_000, family); const fourth = manager.getCurrentOrNextForFamily(family); expect(fourth?.parts.refreshToken).toBe("r2"); const fifth = manager.getCurrentOrNextForFamily(family); expect(fifth?.parts.refreshToken).toBe("r2"); }); it("removes an account and keeps cursor consistent", () => { vi.useFakeTimers(); vi.setSystemTime(new Date(0)); const stored: AccountStorageV4 = { version: 4, accounts: [ { refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 }, { refreshToken: "r2", projectId: "p2", addedAt: 1, lastUsed: 0 }, { refreshToken: "r3", projectId: "p3", addedAt: 1, lastUsed: 0 }, ], activeIndex: 1, }; const manager = new AccountManager(undefined, stored); const family: ModelFamily = "claude"; const picked = manager.getCurrentOrNextForFamily(family); expect(picked?.parts.refreshToken).toBe("r2"); manager.removeAccount(picked!); expect(manager.getAccountCount()).toBe(2); const next = manager.getNextForFamily(family); expect(next?.parts.refreshToken).toBe("r3"); }); it("attaches fallback access tokens only to the matching stored account", () => { const fallback: OAuthAuthDetails = { type: "oauth", refresh: "r2|p2", access: "access-2", expires: 123, }; const stored: AccountStorageV4 = { version: 4, accounts: [ { refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 }, { refreshToken: "r2", projectId: "p2", addedAt: 1, lastUsed: 0 }, ], activeIndex: 0, }; const manager = new AccountManager(fallback, stored); const snapshot = manager.getAccountsSnapshot(); expect(snapshot[0]?.access).toBeUndefined(); expect(snapshot[0]?.expires).toBeUndefined(); expect(snapshot[1]?.access).toBe("access-2"); expect(snapshot[1]?.expires).toBe(123); }); it("debounces toast display for same account", () => { vi.useFakeTimers(); vi.setSystemTime(new Date(0)); const stored: AccountStorageV4 = { version: 4, accounts: [ { refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 }, ], activeIndex: 0, }; const manager = new AccountManager(undefined, stored); expect(manager.shouldShowAccountToast(0)).toBe(true); manager.markToastShown(0); expect(manager.shouldShowAccountToast(0)).toBe(false); expect(manager.shouldShowAccountToast(1)).toBe(true); vi.setSystemTime(new Date(31000)); expect(manager.shouldShowAccountToast(0)).toBe(true); }); describe("header style fallback for Gemini", () => { it("tracks rate limits separately for each header style", () => { const stored: AccountStorageV4 = { version: 4, accounts: [ { refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 }, ], activeIndex: 0, }; const manager = new AccountManager(undefined, stored); const account = manager.getCurrentOrNextForFamily("gemini"); manager.markRateLimited(account!, 60000, "gemini", "antigravity"); expect(manager.isRateLimitedForHeaderStyle(account!, "gemini", "antigravity")).toBe(true); expect(manager.isRateLimitedForHeaderStyle(account!, "gemini", "gemini-cli")).toBe(false); }); it("getAvailableHeaderStyle returns antigravity first for Gemini", () => { const stored: AccountStorageV4 = { version: 4, accounts: [ { refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 }, ], activeIndex: 0, }; const manager = new AccountManager(undefined, stored); const account = manager.getCurrentOrNextForFamily("gemini"); expect(manager.getAvailableHeaderStyle(account!, "gemini")).toBe("antigravity"); }); it("getAvailableHeaderStyle returns gemini-cli when antigravity is rate-limited", () => { const stored: AccountStorageV4 = { version: 4, accounts: [ { refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 }, ], activeIndex: 0, }; const manager = new AccountManager(undefined, stored); const account = manager.getCurrentOrNextForFamily("gemini"); manager.markRateLimited(account!, 60000, "gemini", "antigravity"); expect(manager.getAvailableHeaderStyle(account!, "gemini")).toBe("gemini-cli"); }); it("getAvailableHeaderStyle returns null when both header styles are rate-limited", () => { const stored: AccountStorageV4 = { version: 4, accounts: [ { refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 }, ], activeIndex: 0, }; const manager = new AccountManager(undefined, stored); const account = manager.getCurrentOrNextForFamily("gemini"); manager.markRateLimited(account!, 60000, "gemini", "antigravity"); manager.markRateLimited(account!, 60000, "gemini", "gemini-cli"); expect(manager.getAvailableHeaderStyle(account!, "gemini")).toBeNull(); }); it("getAvailableHeaderStyle always returns antigravity for Claude", () => { const stored: AccountStorageV4 = { version: 4, accounts: [ { refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 }, ], activeIndex: 0, }; const manager = new AccountManager(undefined, stored); const account = manager.getCurrentOrNextForFamily("claude"); expect(manager.getAvailableHeaderStyle(account!, "claude")).toBe("antigravity"); }); it("getAvailableHeaderStyle returns null for Claude when rate-limited", () => { const stored: AccountStorageV4 = { version: 4, accounts: [ { refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 }, ], activeIndex: 0, }; const manager = new AccountManager(undefined, stored); const account = manager.getCurrentOrNextForFamily("claude"); manager.markRateLimited(account!, 60000, "claude", "antigravity"); expect(manager.getAvailableHeaderStyle(account!, "claude")).toBeNull(); }); it("Gemini rate limits expire independently per header style", () => { vi.useFakeTimers(); vi.setSystemTime(new Date(0)); const stored: AccountStorageV4 = { version: 4, accounts: [ { refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 }, ], activeIndex: 0, }; const manager = new AccountManager(undefined, stored); const account = manager.getCurrentOrNextForFamily("gemini"); manager.markRateLimited(account!, 30000, "gemini", "antigravity"); manager.markRateLimited(account!, 60000, "gemini", "gemini-cli"); vi.setSystemTime(new Date(35000)); expect(manager.isRateLimitedForHeaderStyle(account!, "gemini", "antigravity")).toBe(false); expect(manager.isRateLimitedForHeaderStyle(account!, "gemini", "gemini-cli")).toBe(true); expect(manager.getAvailableHeaderStyle(account!, "gemini")).toBe("antigravity"); }); it("getMinWaitTimeForFamily considers both Gemini header styles", () => { vi.useFakeTimers(); vi.setSystemTime(new Date(0)); const stored: AccountStorageV4 = { version: 4, accounts: [ { refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 }, ], activeIndex: 0, }; const manager = new AccountManager(undefined, stored); const account = manager.getCurrentOrNextForFamily("gemini"); manager.markRateLimited(account!, 30000, "gemini", "antigravity"); expect(manager.getMinWaitTimeForFamily("gemini")).toBe(0); manager.markRateLimited(account!, 60000, "gemini", "gemini-cli"); expect(manager.getMinWaitTimeForFamily("gemini")).toBe(30000); }); }); describe("per-family account tracking", () => { it("tracks current account independently per model family", () => { const stored: AccountStorageV4 = { version: 4, accounts: [ { refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 }, { refreshToken: "r2", projectId: "p2", addedAt: 1, lastUsed: 0 }, ], activeIndex: 0, }; const manager = new AccountManager(undefined, stored); const claudeAccount = manager.getCurrentOrNextForFamily("claude"); expect(claudeAccount?.parts.refreshToken).toBe("r1"); manager.markRateLimited(claudeAccount!, 60000, "claude"); const nextClaude = manager.getCurrentOrNextForFamily("claude"); expect(nextClaude?.parts.refreshToken).toBe("r2"); const geminiAccount = manager.getCurrentOrNextForFamily("gemini"); expect(geminiAccount?.parts.refreshToken).toBe("r1"); }); it("switching Claude account does not affect Gemini account selection", () => { const stored: AccountStorageV4 = { version: 4, accounts: [ { refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 }, { refreshToken: "r2", projectId: "p2", addedAt: 1, lastUsed: 0 }, { refreshToken: "r3", projectId: "p3", addedAt: 1, lastUsed: 0 }, ], activeIndex: 0, }; const manager = new AccountManager(undefined, stored); expect(manager.getCurrentOrNextForFamily("gemini")?.parts.refreshToken).toBe("r1"); const claude1 = manager.getCurrentOrNextForFamily("claude"); manager.markRateLimited(claude1!, 60000, "claude"); expect(manager.getCurrentOrNextForFamily("claude")?.parts.refreshToken).toBe("r2"); expect(manager.getCurrentOrNextForFamily("gemini")?.parts.refreshToken).toBe("r1"); const claude2 = manager.getCurrentOrNextForFamily("claude"); manager.markRateLimited(claude2!, 60000, "claude"); expect(manager.getCurrentOrNextForFamily("claude")?.parts.refreshToken).toBe("r3"); expect(manager.getCurrentOrNextForFamily("gemini")?.parts.refreshToken).toBe("r1"); }); it("persists per-family indices to storage", async () => { const stored: AccountStorageV4 = { version: 4, accounts: [ { refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 }, { refreshToken: "r2", projectId: "p2", addedAt: 1, lastUsed: 0 }, ], activeIndex: 0, }; const manager = new AccountManager(undefined, stored); const claude = manager.getCurrentOrNextForFamily("claude"); manager.markRateLimited(claude!, 60000, "claude"); manager.getCurrentOrNextForFamily("claude"); expect(manager.getCurrentAccountForFamily("claude")?.index).toBe(1); expect(manager.getCurrentAccountForFamily("gemini")?.index).toBe(0); }); it("loads per-family indices from storage", () => { const stored: AccountStorageV4 = { version: 4, accounts: [ { refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 }, { refreshToken: "r2", projectId: "p2", addedAt: 1, lastUsed: 0 }, { refreshToken: "r3", projectId: "p3", addedAt: 1, lastUsed: 0 }, ], activeIndex: 0, activeIndexByFamily: { claude: 2, gemini: 1, }, }; const manager = new AccountManager(undefined, stored); expect(manager.getCurrentAccountForFamily("claude")?.parts.refreshToken).toBe("r3"); expect(manager.getCurrentAccountForFamily("gemini")?.parts.refreshToken).toBe("r2"); }); it("falls back to activeIndex when activeIndexByFamily is not present", () => { const stored: AccountStorageV4 = { version: 4, accounts: [ { refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 }, { refreshToken: "r2", projectId: "p2", addedAt: 1, lastUsed: 0 }, ], activeIndex: 1, }; const manager = new AccountManager(undefined, stored); expect(manager.getCurrentAccountForFamily("claude")?.parts.refreshToken).toBe("r2"); expect(manager.getCurrentAccountForFamily("gemini")?.parts.refreshToken).toBe("r2"); }); }); describe("account cooldown (non-429 errors)", () => { it("marks account as cooling down with reason", () => { const stored: AccountStorageV4 = { version: 4, accounts: [ { refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 }, ], activeIndex: 0, }; const manager = new AccountManager(undefined, stored); const account = manager.getCurrentOrNextForFamily("claude"); manager.markAccountCoolingDown(account!, 30000, "auth-failure"); expect(manager.isAccountCoolingDown(account!)).toBe(true); }); it("cooldown expires after duration", () => { vi.useFakeTimers(); vi.setSystemTime(new Date(0)); const stored: AccountStorageV4 = { version: 4, accounts: [ { refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 }, ], activeIndex: 0, }; const manager = new AccountManager(undefined, stored); const account = manager.getCurrentOrNextForFamily("claude"); manager.markAccountCoolingDown(account!, 30000, "network-error"); expect(manager.isAccountCoolingDown(account!)).toBe(true); vi.setSystemTime(new Date(35000)); expect(manager.isAccountCoolingDown(account!)).toBe(false); }); it("clearAccountCooldown removes cooldown state", () => { const stored: AccountStorageV4 = { version: 4, accounts: [ { refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 }, ], activeIndex: 0, }; const manager = new AccountManager(undefined, stored); const account = manager.getCurrentOrNextForFamily("claude"); manager.markAccountCoolingDown(account!, 30000, "auth-failure"); expect(manager.isAccountCoolingDown(account!)).toBe(true); manager.clearAccountCooldown(account!); expect(manager.isAccountCoolingDown(account!)).toBe(false); }); it("cooling down account is skipped in getCurrentOrNextForFamily", () => { const stored: AccountStorageV4 = { version: 4, accounts: [ { refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 }, { refreshToken: "r2", projectId: "p2", addedAt: 1, lastUsed: 0 }, ], activeIndex: 0, }; const manager = new AccountManager(undefined, stored); const account1 = manager.getCurrentOrNextForFamily("claude"); manager.markAccountCoolingDown(account1!, 30000, "project-error"); const next = manager.getCurrentOrNextForFamily("claude"); expect(next?.parts.refreshToken).toBe("r2"); }); it("cooldown is independent from rate limits", () => { const stored: AccountStorageV4 = { version: 4, accounts: [ { refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 }, ], activeIndex: 0, }; const manager = new AccountManager(undefined, stored); const account = manager.getCurrentOrNextForFamily("gemini"); manager.markAccountCoolingDown(account!, 30000, "auth-failure"); expect(manager.isAccountCoolingDown(account!)).toBe(true); expect(manager.isRateLimitedForHeaderStyle(account!, "gemini", "antigravity")).toBe(false); expect(manager.isRateLimitedForHeaderStyle(account!, "gemini", "gemini-cli")).toBe(false); }); }); describe("account selection strategies", () => { describe("sticky strategy (default)", () => { it("returns same account on consecutive calls", () => { const stored: AccountStorageV4 = { version: 4, accounts: [ { refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 }, { refreshToken: "r2", projectId: "p2", addedAt: 1, lastUsed: 0 }, ], activeIndex: 0, }; const manager = new AccountManager(undefined, stored); const first = manager.getCurrentOrNextForFamily("claude", null, "sticky"); const second = manager.getCurrentOrNextForFamily("claude", null, "sticky"); const third = manager.getCurrentOrNextForFamily("claude", null, "sticky"); expect(first?.index).toBe(0); expect(second?.index).toBe(0); expect(third?.index).toBe(0); }); it("switches account only when current is rate-limited", () => { const stored: AccountStorageV4 = { version: 4, accounts: [ { refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 }, { refreshToken: "r2", projectId: "p2", addedAt: 1, lastUsed: 0 }, ], activeIndex: 0, }; const manager = new AccountManager(undefined, stored); const first = manager.getCurrentOrNextForFamily("claude", null, "sticky"); expect(first?.index).toBe(0); manager.markRateLimited(first!, 60000, "claude"); const second = manager.getCurrentOrNextForFamily("claude", null, "sticky"); expect(second?.index).toBe(1); }); }); describe("round-robin strategy", () => { it("rotates to next account on each call", () => { const stored: AccountStorageV4 = { version: 4, accounts: [ { refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 }, { refreshToken: "r2", projectId: "p2", addedAt: 1, lastUsed: 0 }, { refreshToken: "r3", projectId: "p3", addedAt: 1, lastUsed: 0 }, ], activeIndex: 0, }; const manager = new AccountManager(undefined, stored); const first = manager.getCurrentOrNextForFamily("claude", null, "round-robin"); const second = manager.getCurrentOrNextForFamily("claude", null, "round-robin"); const third = manager.getCurrentOrNextForFamily("claude", null, "round-robin"); const fourth = manager.getCurrentOrNextForFamily("claude", null, "round-robin"); const indices = [first?.index, second?.index, third?.index, fourth?.index]; expect(new Set(indices).size).toBeGreaterThanOrEqual(2); }); it("skips rate-limited accounts", () => { const stored: AccountStorageV4 = { version: 4, accounts: [ { refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 }, { refreshToken: "r2", projectId: "p2", addedAt: 1, lastUsed: 0 }, { refreshToken: "r3", projectId: "p3", addedAt: 1, lastUsed: 0 }, ], activeIndex: 0, }; const manager = new AccountManager(undefined, stored); const accounts = manager.getAccounts(); manager.markRateLimited(accounts[1]!, 60000, "claude"); const first = manager.getCurrentOrNextForFamily("claude", null, "round-robin"); const second = manager.getCurrentOrNextForFamily("claude", null, "round-robin"); expect(first?.index).not.toBe(1); expect(second?.index).not.toBe(1); }); }); describe("hybrid strategy", () => { it("returns fresh (untouched) accounts first", () => { const stored: AccountStorageV4 = { version: 4, accounts: [ { refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 }, { refreshToken: "r2", projectId: "p2", addedAt: 1, lastUsed: 0 }, { refreshToken: "r3", projectId: "p3", addedAt: 1, lastUsed: 0 }, ], activeIndex: 0, }; const manager = new AccountManager(undefined, stored); const first = manager.getCurrentOrNextForFamily("claude", null, "hybrid"); const second = manager.getCurrentOrNextForFamily("claude", null, "hybrid"); const third = manager.getCurrentOrNextForFamily("claude", null, "hybrid"); const indices = [first?.index, second?.index, third?.index]; expect(indices).toContain(0); expect(indices).toContain(1); expect(indices).toContain(2); }); it("continues to return valid accounts after all touched", () => { const stored: AccountStorageV4 = { version: 4, accounts: [ { refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 }, { refreshToken: "r2", projectId: "p2", addedAt: 1, lastUsed: 0 }, ], activeIndex: 0, }; const manager = new AccountManager(undefined, stored); manager.getCurrentOrNextForFamily("claude", null, "hybrid"); manager.getCurrentOrNextForFamily("claude", null, "hybrid"); const third = manager.getCurrentOrNextForFamily("claude", null, "hybrid"); const fourth = manager.getCurrentOrNextForFamily("claude", null, "hybrid"); expect(third).not.toBeNull(); expect(fourth).not.toBeNull(); expect([0, 1]).toContain(third?.index); expect([0, 1]).toContain(fourth?.index); }); }); describe("hybrid strategy with token bucket", () => { it("returns account based on health and token availability", () => { const stored: AccountStorageV4 = { version: 4, accounts: [ { refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 }, { refreshToken: "r2", projectId: "p2", addedAt: 1, lastUsed: 0 }, { refreshToken: "r3", projectId: "p3", addedAt: 1, lastUsed: 0 }, ], activeIndex: 0, }; const manager = new AccountManager(undefined, stored); const first = manager.getCurrentOrNextForFamily("claude", null, "hybrid"); expect(first).not.toBeNull(); expect([0, 1, 2]).toContain(first?.index); }); it("skips rate-limited accounts", () => { const stored: AccountStorageV4 = { version: 4, accounts: [ { refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 }, { refreshToken: "r2", projectId: "p2", addedAt: 1, lastUsed: 0 }, ], activeIndex: 0, }; const manager = new AccountManager(undefined, stored); const accounts = manager.getAccounts(); manager.markRateLimited(accounts[0]!, 60000, "claude"); const selected = manager.getCurrentOrNextForFamily("claude", null, "hybrid"); expect(selected?.index).toBe(1); }); it("skips cooling down accounts", () => { const stored: AccountStorageV4 = { version: 4, accounts: [ { refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 }, { refreshToken: "r2", projectId: "p2", addedAt: 1, lastUsed: 0 }, ], activeIndex: 0, }; const manager = new AccountManager(undefined, stored); const accounts = manager.getAccounts(); manager.markAccountCoolingDown(accounts[0]!, 60000, "auth-failure"); const selected = manager.getCurrentOrNextForFamily("claude", null, "hybrid"); expect(selected?.index).toBe(1); }); it("falls back to sticky when all accounts unavailable", () => { vi.useFakeTimers(); vi.setSystemTime(new Date(0)); const stored: AccountStorageV4 = { version: 4, accounts: [ { refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 }, ], activeIndex: 0, }; const manager = new AccountManager(undefined, stored); const selected = manager.getCurrentOrNextForFamily("claude", null, "hybrid"); expect(selected?.index).toBe(0); }); it("updates lastUsed and currentAccountIndexByFamily on selection", () => { vi.useFakeTimers(); vi.setSystemTime(new Date(5000)); const stored: AccountStorageV4 = { version: 4, accounts: [ { refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 }, { refreshToken: "r2", projectId: "p2", addedAt: 1, lastUsed: 0 }, ], activeIndex: 0, }; const manager = new AccountManager(undefined, stored); const selected = manager.getCurrentOrNextForFamily("claude", null, "hybrid"); expect(selected).not.toBeNull(); expect(selected!.lastUsed).toBe(5000); expect(manager.getCurrentAccountForFamily("claude")?.index).toBe(selected?.index); }); }); }); describe("touchedForQuota tracking", () => { it("marks account as touched with timestamp", () => { vi.useFakeTimers(); vi.setSystemTime(new Date(1000)); const stored: AccountStorageV4 = { version: 4, accounts: [ { refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 }, ], activeIndex: 0, }; const manager = new AccountManager(undefined, stored); const account = manager.getAccounts()[0]!; manager.markTouchedForQuota(account, "claude:antigravity"); expect(account.touchedForQuota["claude:antigravity"]).toBe(1000); }); it("isFreshForQuota returns true for untouched accounts", () => { const stored: AccountStorageV4 = { version: 4, accounts: [ { refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 }, ], activeIndex: 0, }; const manager = new AccountManager(undefined, stored); const account = manager.getAccounts()[0]!; expect(manager.isFreshForQuota(account, "claude:antigravity")).toBe(true); }); it("isFreshForQuota returns false for recently touched accounts", () => { vi.useFakeTimers(); vi.setSystemTime(new Date(1000)); const stored: AccountStorageV4 = { version: 4, accounts: [ { refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 }, ], activeIndex: 0, }; const manager = new AccountManager(undefined, stored); const account = manager.getAccounts()[0]!; manager.markTouchedForQuota(account, "claude:antigravity"); expect(manager.isFreshForQuota(account, "claude:antigravity")).toBe(false); }); it("isFreshForQuota returns true after quota reset time passes", () => { vi.useFakeTimers(); vi.setSystemTime(new Date(1000)); const stored: AccountStorageV4 = { version: 4, accounts: [ { refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 }, ], activeIndex: 0, }; const manager = new AccountManager(undefined, stored); const account = manager.getAccounts()[0]!; manager.markTouchedForQuota(account, "claude"); expect(manager.isFreshForQuota(account, "claude")).toBe(false); manager.markRateLimited(account, 60000, "claude", "antigravity"); vi.setSystemTime(new Date(70000)); expect(manager.isFreshForQuota(account, "claude")).toBe(true); }); }); describe("consecutiveFailures tracking", () => { it("initializes consecutiveFailures as undefined", () => { const stored: AccountStorageV4 = { version: 4, accounts: [ { refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 }, ], activeIndex: 0, }; const manager = new AccountManager(undefined, stored); const account = manager.getAccounts()[0]!; expect(account.consecutiveFailures).toBeUndefined(); }); it("can increment and reset consecutiveFailures", () => { const stored: AccountStorageV4 = { version: 4, accounts: [ { refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 }, ], activeIndex: 0, }; const manager = new AccountManager(undefined, stored); const account = manager.getAccounts()[0]!; account.consecutiveFailures = (account.consecutiveFailures ?? 0) + 1; expect(account.consecutiveFailures).toBe(1); account.consecutiveFailures = (account.consecutiveFailures ?? 0) + 1; expect(account.consecutiveFailures).toBe(2); account.consecutiveFailures = 0; expect(account.consecutiveFailures).toBe(0); }); }); describe("Issue #147: headerStyle-aware account selection", () => { it("skips account when requested headerStyle is rate-limited even if other style is available", () => { const stored: AccountStorageV4 = { version: 4, accounts: [ { refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 }, { refreshToken: "r2", projectId: "p2", addedAt: 1, lastUsed: 0 }, ], activeIndex: 0, activeIndexByFamily: { claude: 0, gemini: 0 }, }; const manager = new AccountManager(undefined, stored); const firstAccount = manager.getCurrentOrNextForFamily("gemini"); // Mark ONLY antigravity as rate-limited (gemini-cli is still available) manager.markRateLimited(firstAccount!, 60000, "gemini", "antigravity"); // Verify: antigravity is limited, gemini-cli is not expect(manager.isRateLimitedForHeaderStyle(firstAccount!, "gemini", "antigravity")).toBe(true); expect(manager.isRateLimitedForHeaderStyle(firstAccount!, "gemini", "gemini-cli")).toBe(false); // BUG: When we explicitly request antigravity headerStyle, // we should skip this account and get the next one // Current behavior: returns the same account because "family" is not fully limited const nextAccount = manager.getCurrentOrNextForFamily( "gemini", null, "sticky", "antigravity" // Explicitly requesting antigravity ); // Verifies headerStyle-aware account selection: should skip account 0 // because its antigravity quota is limited, even though gemini-cli is available expect(nextAccount?.index).toBe(1); }); it("returns same account when a different headerStyle is rate-limited", () => { const stored: AccountStorageV4 = { version: 4, accounts: [ { refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 }, { refreshToken: "r2", projectId: "p2", addedAt: 1, lastUsed: 0 }, ], activeIndex: 0, activeIndexByFamily: { claude: 0, gemini: 0 }, }; const manager = new AccountManager(undefined, stored); const firstAccount = manager.getCurrentOrNextForFamily("gemini"); // Mark gemini-cli as rate-limited (antigravity is still available) manager.markRateLimited(firstAccount!, 60000, "gemini", "gemini-cli"); // When requesting antigravity, should return the same account // because antigravity quota is still available const nextAccount = manager.getCurrentOrNextForFamily( "gemini", null, "sticky", "antigravity" // Requesting antigravity which is NOT limited ); expect(nextAccount?.index).toBe(0); // Should stay on account 0 }); }); describe("Issue #174: saveToDisk throttling", () => { it("requestSaveToDisk coalesces multiple calls into one write", async () => { vi.useFakeTimers(); const stored: AccountStorageV4 = { version: 4, accounts: [ { refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 }, ], activeIndex: 0, }; const manager = new AccountManager(undefined, stored); const saveSpy = vi.spyOn(manager, "saveToDisk").mockResolvedValue(); manager.requestSaveToDisk(); manager.requestSaveToDisk(); manager.requestSaveToDisk(); expect(saveSpy).not.toHaveBeenCalled(); await vi.advanceTimersByTimeAsync(1500); expect(saveSpy).toHaveBeenCalledTimes(1); saveSpy.mockRestore(); }); it("flushSaveToDisk waits for pending save to complete", async () => { vi.useFakeTimers(); const stored: AccountStorageV4 = { version: 4, accounts: [ { refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 }, ], activeIndex: 0, }; const manager = new AccountManager(undefined, stored); const saveSpy = vi.spyOn(manager, "saveToDisk").mockResolvedValue(); manager.requestSaveToDisk(); const flushPromise = manager.flushSaveToDisk(); await vi.advanceTimersByTimeAsync(1500); await flushPromise; expect(saveSpy).toHaveBeenCalledTimes(1); saveSpy.mockRestore(); }); it("does not save again if no new requestSaveToDisk after flush", async () => { vi.useFakeTimers(); const stored: AccountStorageV4 = { version: 4, accounts: [ { refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 }, ], activeIndex: 0, }; const manager = new AccountManager(undefined, stored); const saveSpy = vi.spyOn(manager, "saveToDisk").mockResolvedValue(); manager.requestSaveToDisk(); await vi.advanceTimersByTimeAsync(1500); expect(saveSpy).toHaveBeenCalledTimes(1); await vi.advanceTimersByTimeAsync(3000); expect(saveSpy).toHaveBeenCalledTimes(1); saveSpy.mockRestore(); }); }); describe("Rate Limit Reason Classification", () => { it("getMinWaitTimeForFamily respects strict header style", () => { vi.useFakeTimers(); vi.setSystemTime(new Date(0)); const stored: AccountStorageV4 = { version: 4, accounts: [ { refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 }, ], activeIndex: 0, }; const manager = new AccountManager(undefined, stored); const account = manager.getCurrentOrNextForFamily("gemini"); manager.markRateLimited(account!, 30000, "gemini", "antigravity", "gemini-3-pro-image"); expect( manager.getMinWaitTimeForFamily( "gemini", "gemini-3-pro-image", "antigravity", true, ), ).toBe(30000); expect(manager.getMinWaitTimeForFamily("gemini", "gemini-3-pro-image")).toBe(0); }); describe("parseRateLimitReason", () => { it("parses QUOTA_EXHAUSTED from reason field", () => { expect(parseRateLimitReason("QUOTA_EXHAUSTED", undefined)).toBe("QUOTA_EXHAUSTED"); expect(parseRateLimitReason("quota_exhausted", undefined)).toBe("QUOTA_EXHAUSTED"); }); it("parses RATE_LIMIT_EXCEEDED from reason field", () => { expect(parseRateLimitReason("RATE_LIMIT_EXCEEDED", undefined)).toBe("RATE_LIMIT_EXCEEDED"); }); it("parses MODEL_CAPACITY_EXHAUSTED from reason field", () => { expect(parseRateLimitReason("MODEL_CAPACITY_EXHAUSTED", undefined)).toBe("MODEL_CAPACITY_EXHAUSTED"); }); it("falls back to message parsing when reason is absent", () => { expect(parseRateLimitReason(undefined, "Rate limit exceeded per minute")).toBe("RATE_LIMIT_EXCEEDED"); expect(parseRateLimitReason(undefined, "Too many requests")).toBe("RATE_LIMIT_EXCEEDED"); expect(parseRateLimitReason(undefined, "Quota exhausted for today")).toBe("QUOTA_EXHAUSTED"); }); it("returns UNKNOWN when no pattern matches", () => { expect(parseRateLimitReason(undefined, "Some other error")).toBe("UNKNOWN"); expect(parseRateLimitReason(undefined, undefined)).toBe("UNKNOWN"); }); }); describe("calculateBackoffMs", () => { it("uses retryAfterMs when provided", () => { expect(calculateBackoffMs("QUOTA_EXHAUSTED", 0, 120_000)).toBe(120_000); expect(calculateBackoffMs("RATE_LIMIT_EXCEEDED", 0, 45_000)).toBe(45_000); }); it("enforces minimum 2s backoff", () => { expect(calculateBackoffMs("QUOTA_EXHAUSTED", 0, 500)).toBe(2_000); expect(calculateBackoffMs("RATE_LIMIT_EXCEEDED", 0, 1_000)).toBe(2_000); }); it("applies exponential backoff for QUOTA_EXHAUSTED", () => { expect(calculateBackoffMs("QUOTA_EXHAUSTED", 0)).toBe(60_000); expect(calculateBackoffMs("QUOTA_EXHAUSTED", 1)).toBe(300_000); expect(calculateBackoffMs("QUOTA_EXHAUSTED", 2)).toBe(1_800_000); expect(calculateBackoffMs("QUOTA_EXHAUSTED", 3)).toBe(7_200_000); expect(calculateBackoffMs("QUOTA_EXHAUSTED", 10)).toBe(7_200_000); }); it("returns fixed backoff for RATE_LIMIT_EXCEEDED", () => { expect(calculateBackoffMs("RATE_LIMIT_EXCEEDED", 0)).toBe(30_000); expect(calculateBackoffMs("RATE_LIMIT_EXCEEDED", 5)).toBe(30_000); }); it("returns short backoff for MODEL_CAPACITY_EXHAUSTED", () => { // Base backoff is 45s with ±15s jitter (range: 30s to 60s) const result = calculateBackoffMs("MODEL_CAPACITY_EXHAUSTED", 0); expect(result).toBeGreaterThanOrEqual(30_000); expect(result).toBeLessThanOrEqual(60_000); }); it("returns soft retry for SERVER_ERROR", () => { expect(calculateBackoffMs("SERVER_ERROR", 0)).toBe(20_000); }); it("returns default backoff for UNKNOWN", () => { expect(calculateBackoffMs("UNKNOWN", 0)).toBe(60_000); }); }); describe("markRateLimitedWithReason", () => { it("tracks consecutive failures and applies escalating backoff", () => { vi.useFakeTimers(); vi.setSystemTime(1000); const stored: AccountStorageV4 = { version: 4, accounts: [ { refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 }, ], activeIndex: 0, }; const manager = new AccountManager(undefined, stored); const account = manager.getAccounts()[0]!; const backoff1 = manager.markRateLimitedWithReason( account, "gemini", "antigravity", null, "QUOTA_EXHAUSTED" ); expect(backoff1).toBe(60_000); expect(account.consecutiveFailures).toBe(1); const backoff2 = manager.markRateLimitedWithReason( account, "gemini", "antigravity", null, "QUOTA_EXHAUSTED" ); expect(backoff2).toBe(300_000); expect(account.consecutiveFailures).toBe(2); const backoff3 = manager.markRateLimitedWithReason( account, "gemini", "antigravity", null, "QUOTA_EXHAUSTED" ); expect(backoff3).toBe(1_800_000); expect(account.consecutiveFailures).toBe(3); vi.useRealTimers(); }); it("uses provided retryAfterMs over calculated backoff", () => { vi.useFakeTimers(); vi.setSystemTime(1000); const stored: AccountStorageV4 = { version: 4, accounts: [ { refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 }, ], activeIndex: 0, }; const manager = new AccountManager(undefined, stored); const account = manager.getAccounts()[0]!; const backoff = manager.markRateLimitedWithReason( account, "gemini", "antigravity", null, "QUOTA_EXHAUSTED", 180_000 ); expect(backoff).toBe(180_000); vi.useRealTimers(); }); }); describe("markRequestSuccess", () => { it("resets consecutive failure counter", () => { const stored: AccountStorageV4 = { version: 4, accounts: [ { refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 }, ], activeIndex: 0, }; const manager = new AccountManager(undefined, stored); const account = manager.getAccounts()[0]!; account.consecutiveFailures = 5; manager.markRequestSuccess(account); expect(account.consecutiveFailures).toBe(0); }); }); describe("Optimistic Reset", () => { it("shouldTryOptimisticReset returns true when min wait time <= 2s", () => { vi.useFakeTimers(); vi.setSystemTime(10_000); const stored: AccountStorageV4 = { version: 4, accounts: [ { refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0, rateLimitResetTimes: { "gemini-antigravity": 11_500, "gemini-cli": 11_500 } }, ], activeIndex: 0, }; const manager = new AccountManager(undefined, stored); expect(manager.shouldTryOptimisticReset("gemini")).toBe(true); vi.useRealTimers(); }); it("shouldTryOptimisticReset returns false when min wait time > 2s", () => { vi.useFakeTimers(); vi.setSystemTime(10_000); const stored: AccountStorageV4 = { version: 4, accounts: [ { refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0, rateLimitResetTimes: { "gemini-antigravity": 15_000, "gemini-cli": 15_000 } }, ], activeIndex: 0, }; const manager = new AccountManager(undefined, stored); expect(manager.shouldTryOptimisticReset("gemini")).toBe(false); vi.useRealTimers(); }); it("shouldTryOptimisticReset returns false when accounts are available", () => { const stored: AccountStorageV4 = { version: 4, accounts: [ { refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 }, ], activeIndex: 0, }; const manager = new AccountManager(undefined, stored); expect(manager.shouldTryOptimisticReset("gemini")).toBe(false); }); it("clearAllRateLimitsForFamily clears rate limits and failure counters", () => { vi.useFakeTimers(); vi.setSystemTime(10_000); const stored: AccountStorageV4 = { version: 4, accounts: [ { refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0, rateLimitResetTimes: { "gemini-antigravity": 70_000, "gemini-cli": 80_000 } }, { refreshToken: "r2", projectId: "p2", addedAt: 2, lastUsed: 0, rateLimitResetTimes: { "gemini-antigravity": 90_000 } }, ], activeIndex: 0, }; const manager = new AccountManager(undefined, stored); const accounts = manager.getAccounts(); accounts[0]!.consecutiveFailures = 3; accounts[1]!.consecutiveFailures = 2; manager.clearAllRateLimitsForFamily("gemini"); expect(accounts[0]!.rateLimitResetTimes["gemini-antigravity"]).toBeUndefined(); expect(accounts[0]!.rateLimitResetTimes["gemini-cli"]).toBeUndefined(); expect(accounts[1]!.rateLimitResetTimes["gemini-antigravity"]).toBeUndefined(); expect(accounts[0]!.consecutiveFailures).toBe(0); expect(accounts[1]!.consecutiveFailures).toBe(0); vi.useRealTimers(); }); }); }); describe("Failure TTL Expiration", () => { it("resets consecutiveFailures when lastFailureTime exceeds TTL", () => { vi.useFakeTimers(); vi.setSystemTime(new Date(0)); const stored: AccountStorageV4 = { version: 4, accounts: [ { refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 }, ], activeIndex: 0, }; const manager = new AccountManager(undefined, stored); const account = manager.getCurrentOrNextForFamily("claude"); // First failure manager.markRateLimitedWithReason(account!, "claude", "antigravity", null, "QUOTA_EXHAUSTED", null, 3600_000); expect(account!.consecutiveFailures).toBe(1); expect(account!.lastFailureTime).toBe(0); // Advance time past TTL (1 hour = 3600s) vi.setSystemTime(new Date(3700_000)); // 3700 seconds later // Next failure should reset count because TTL expired manager.markRateLimitedWithReason(account!, "claude", "antigravity", null, "QUOTA_EXHAUSTED", null, 3600_000); expect(account!.consecutiveFailures).toBe(1); // Reset to 0, then +1 vi.useRealTimers(); }); it("keeps consecutiveFailures when within TTL", () => { vi.useFakeTimers(); vi.setSystemTime(new Date(0)); const stored: AccountStorageV4 = { version: 4, accounts: [ { refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 }, ], activeIndex: 0, }; const manager = new AccountManager(undefined, stored); const account = manager.getCurrentOrNextForFamily("claude"); // First failure manager.markRateLimitedWithReason(account!, "claude", "antigravity", null, "QUOTA_EXHAUSTED", null, 3600_000); expect(account!.consecutiveFailures).toBe(1); // Advance time within TTL vi.setSystemTime(new Date(1800_000)); // 30 minutes later (within 1 hour TTL) // Next failure should increment manager.markRateLimitedWithReason(account!, "claude", "antigravity", null, "QUOTA_EXHAUSTED", null, 3600_000); expect(account!.consecutiveFailures).toBe(2); vi.useRealTimers(); }); }); describe("Fingerprint History", () => { it("regenerateAccountFingerprint saves old fingerprint to history", () => { const stored: AccountStorageV4 = { version: 4, accounts: [ { refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 }, ], activeIndex: 0, }; const manager = new AccountManager(undefined, stored); const account = manager.getCurrentOrNextForFamily("claude"); // Set initial fingerprint const originalFingerprint = account!.fingerprint; // Regenerate const newFingerprint = manager.regenerateAccountFingerprint(0); expect(newFingerprint).not.toBeNull(); expect(newFingerprint).not.toEqual(originalFingerprint); expect(account!.fingerprintHistory?.length).toBeGreaterThanOrEqual(0); }); it("restoreAccountFingerprint restores from history", () => { vi.useFakeTimers(); vi.setSystemTime(new Date(1000)); // Start at 1000 to avoid 0 being falsy const stored: AccountStorageV4 = { version: 4, accounts: [ { refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 }, ], activeIndex: 0, }; const manager = new AccountManager(undefined, stored); manager.getCurrentOrNextForFamily("claude"); // Generate initial fingerprint const original = manager.regenerateAccountFingerprint(0); const originalDeviceId = original?.deviceId; vi.setSystemTime(new Date(2000)); // Generate second fingerprint (pushes first to history at index 0) manager.regenerateAccountFingerprint(0); // History[0] should be the "original" fingerprint const history = manager.getAccountFingerprintHistory(0); expect(history.length).toBeGreaterThanOrEqual(1); expect(history[0]?.fingerprint.deviceId).toBe(originalDeviceId); vi.setSystemTime(new Date(3000)); // Restore from history[0] - should get the "original" back // Note: restore also pushes current to history, so after restore: // - Current = original fingerprint // - History[0] = what was current before restore const restored = manager.restoreAccountFingerprint(0, 0); expect(restored).not.toBeNull(); expect(restored?.deviceId).toBe(originalDeviceId); vi.useRealTimers(); }); it("getAccountFingerprintHistory returns empty array for new account", () => { const stored: AccountStorageV4 = { version: 4, accounts: [ { refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 }, ], activeIndex: 0, }; const manager = new AccountManager(undefined, stored); const history = manager.getAccountFingerprintHistory(0); expect(history).toEqual([]); }); it("limits fingerprint history to MAX_FINGERPRINT_HISTORY", () => { const stored: AccountStorageV4 = { version: 4, accounts: [ { refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 }, ], activeIndex: 0, }; const manager = new AccountManager(undefined, stored); // Regenerate 7 times (should only keep 5 in history) for (let i = 0; i < 7; i++) { manager.regenerateAccountFingerprint(0); } const history = manager.getAccountFingerprintHistory(0); expect(history.length).toBeLessThanOrEqual(5); }); }); describe("soft quota threshold", () => { it("skips account over soft quota threshold in sticky mode", () => { const stored: AccountStorageV4 = { version: 4, accounts: [ { refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 }, { refreshToken: "r2", projectId: "p2", addedAt: 2, lastUsed: 0 }, ], activeIndex: 0, }; const manager = new AccountManager(undefined, stored); manager.updateQuotaCache(0, { claude: { remainingFraction: 0.05, modelCount: 1 } }); const account = manager.getCurrentOrNextForFamily("claude", null, "sticky", "antigravity", false, 90); expect(account?.parts.refreshToken).toBe("r2"); }); it("allows account under soft quota threshold", () => { const stored: AccountStorageV4 = { version: 4, accounts: [ { refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 }, ], activeIndex: 0, }; const manager = new AccountManager(undefined, stored); manager.updateQuotaCache(0, { claude: { remainingFraction: 0.15, modelCount: 1 } }); const account = manager.getCurrentOrNextForFamily("claude", null, "sticky", "antigravity", false, 90); expect(account?.parts.refreshToken).toBe("r1"); }); it("threshold of 100 disables soft quota protection", () => { const stored: AccountStorageV4 = { version: 4, accounts: [ { refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 }, ], activeIndex: 0, }; const manager = new AccountManager(undefined, stored); manager.updateQuotaCache(0, { claude: { remainingFraction: 0.01, modelCount: 1 } }); const account = manager.getCurrentOrNextForFamily("claude", null, "sticky", "antigravity", false, 100); expect(account?.parts.refreshToken).toBe("r1"); }); it("returns null when all accounts over threshold", () => { const stored: AccountStorageV4 = { version: 4, accounts: [ { refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 }, { refreshToken: "r2", projectId: "p2", addedAt: 2, lastUsed: 0 }, ], activeIndex: 0, }; const manager = new AccountManager(undefined, stored); manager.updateQuotaCache(0, { claude: { remainingFraction: 0.05, modelCount: 1 } }); manager.updateQuotaCache(1, { claude: { remainingFraction: 0.08, modelCount: 1 } }); const account = manager.getCurrentOrNextForFamily("claude", null, "sticky", "antigravity", false, 90); expect(account).toBeNull(); }); it("skips account over threshold in round-robin mode", () => { const stored: AccountStorageV4 = { version: 4, accounts: [ { refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 }, { refreshToken: "r2", projectId: "p2", addedAt: 2, lastUsed: 0 }, ], activeIndex: 0, }; const manager = new AccountManager(undefined, stored); manager.updateQuotaCache(0, { claude: { remainingFraction: 0.05, modelCount: 1 } }); const account = manager.getCurrentOrNextForFamily("claude", null, "round-robin", "antigravity", false, 90); expect(account?.parts.refreshToken).toBe("r2"); }); it("account without cached quota is not skipped", () => { const stored: AccountStorageV4 = { version: 4, accounts: [ { refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 }, ], activeIndex: 0, }; const manager = new AccountManager(undefined, stored); const account = manager.getCurrentOrNextForFamily("claude", null, "sticky", "antigravity", false, 90); expect(account?.parts.refreshToken).toBe("r1"); }); it("handles remainingFraction of 0 (fully exhausted)", () => { const stored: AccountStorageV4 = { version: 4, accounts: [ { refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 }, { refreshToken: "r2", projectId: "p2", addedAt: 2, lastUsed: 0 }, ], activeIndex: 0, }; const manager = new AccountManager(undefined, stored); manager.updateQuotaCache(0, { claude: { remainingFraction: 0, modelCount: 1 } }); const account = manager.getCurrentOrNextForFamily("claude", null, "sticky", "antigravity", false, 90); expect(account?.parts.refreshToken).toBe("r2"); }); it("ignores stale quota cache (over 10 minutes old)", () => { vi.useFakeTimers(); vi.setSystemTime(new Date(0)); const stored: AccountStorageV4 = { version: 4, accounts: [ { refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 }, ], activeIndex: 0, }; const manager = new AccountManager(undefined, stored); manager.updateQuotaCache(0, { claude: { remainingFraction: 0.05, modelCount: 1 } }); vi.setSystemTime(new Date(11 * 60 * 1000)); const account = manager.getCurrentOrNextForFamily("claude", null, "sticky", "antigravity", false, 90); expect(account?.parts.refreshToken).toBe("r1"); vi.useRealTimers(); }); it("fails open when cachedQuotaUpdatedAt is missing", () => { const stored: AccountStorageV4 = { version: 4, accounts: [ { refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 }, ], activeIndex: 0, }; const manager = new AccountManager(undefined, stored); const acc = (manager as any).accounts[0]; acc.cachedQuota = { claude: { remainingFraction: 0.05, modelCount: 1 } }; acc.cachedQuotaUpdatedAt = undefined; const account = manager.getCurrentOrNextForFamily("claude", null, "sticky", "antigravity", false, 90); expect(account?.parts.refreshToken).toBe("r1"); }); }); describe("getMinWaitTimeForSoftQuota", () => { it("returns 0 when accounts are available (under threshold)", () => { const stored: AccountStorageV4 = { version: 4, accounts: [ { refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 }, ], activeIndex: 0, }; const manager = new AccountManager(undefined, stored); manager.updateQuotaCache(0, { claude: { remainingFraction: 0.15, modelCount: 1 } }); const waitMs = manager.getMinWaitTimeForSoftQuota("claude", 90, 10 * 60 * 1000); expect(waitMs).toBe(0); }); it("returns null when no resetTime available", () => { const stored: AccountStorageV4 = { version: 4, accounts: [ { refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 }, ], activeIndex: 0, }; const manager = new AccountManager(undefined, stored); manager.updateQuotaCache(0, { claude: { remainingFraction: 0.05, modelCount: 1 } }); const waitMs = manager.getMinWaitTimeForSoftQuota("claude", 90, 10 * 60 * 1000); expect(waitMs).toBeNull(); }); it("returns wait time from resetTime when over threshold", () => { vi.useFakeTimers(); vi.setSystemTime(new Date("2026-01-28T10:00:00Z")); const stored: AccountStorageV4 = { version: 4, accounts: [ { refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 }, ], activeIndex: 0, }; const manager = new AccountManager(undefined, stored); manager.updateQuotaCache(0, { claude: { remainingFraction: 0.05, resetTime: "2026-01-28T15:00:00Z", modelCount: 1 } }); const waitMs = manager.getMinWaitTimeForSoftQuota("claude", 90, 10 * 60 * 1000); expect(waitMs).toBe(5 * 60 * 60 * 1000); vi.useRealTimers(); }); it("returns null (fail-open) when resetTime is in the past", () => { vi.useFakeTimers(); vi.setSystemTime(new Date("2026-01-28T16:00:00Z")); const stored: AccountStorageV4 = { version: 4, accounts: [ { refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 }, ], activeIndex: 0, }; const manager = new AccountManager(undefined, stored); manager.updateQuotaCache(0, { claude: { remainingFraction: 0.05, resetTime: "2026-01-28T15:00:00Z", modelCount: 1 } }); const waitMs = manager.getMinWaitTimeForSoftQuota("claude", 90, 10 * 60 * 1000); expect(waitMs).toBe(null); vi.useRealTimers(); }); it("returns minimum wait time across multiple accounts", () => { vi.useFakeTimers(); vi.setSystemTime(new Date("2026-01-28T10:00:00Z")); const stored: AccountStorageV4 = { version: 4, accounts: [ { refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 }, { refreshToken: "r2", projectId: "p2", addedAt: 2, lastUsed: 0 }, ], activeIndex: 0, }; const manager = new AccountManager(undefined, stored); manager.updateQuotaCache(0, { claude: { remainingFraction: 0.05, resetTime: "2026-01-28T15:00:00Z", modelCount: 1 } }); manager.updateQuotaCache(1, { claude: { remainingFraction: 0.08, resetTime: "2026-01-28T12:00:00Z", modelCount: 1 } }); const waitMs = manager.getMinWaitTimeForSoftQuota("claude", 90, 10 * 60 * 1000); expect(waitMs).toBe(2 * 60 * 60 * 1000); vi.useRealTimers(); }); }); }); describe("resolveQuotaGroup", () => { it("returns model-based quota group when model is provided", () => { expect(resolveQuotaGroup("claude", "claude-opus-4-6-thinking")).toBe("claude"); expect(resolveQuotaGroup("gemini", "gemini-2.5-pro")).toBe("gemini-pro"); expect(resolveQuotaGroup("gemini", "gemini-2.5-flash")).toBe("gemini-flash"); }); it("falls back to claude for claude family when no model", () => { expect(resolveQuotaGroup("claude", null)).toBe("claude"); expect(resolveQuotaGroup("claude", undefined)).toBe("claude"); }); it("falls back to gemini-pro for gemini family when no model", () => { expect(resolveQuotaGroup("gemini", null)).toBe("gemini-pro"); expect(resolveQuotaGroup("gemini", undefined)).toBe("gemini-pro"); }); it("model takes precedence over family", () => { // Even if family says claude, model determines the quota group expect(resolveQuotaGroup("gemini", "gemini-2.5-flash")).toBe("gemini-flash"); expect(resolveQuotaGroup("gemini", "gemini-3-pro")).toBe("gemini-pro"); }); }); ================================================ FILE: src/plugin/accounts.ts ================================================ import { formatRefreshParts, parseRefreshParts } from "./auth"; import { loadAccounts, saveAccounts, type AccountStorageV4, type AccountMetadataV3, type RateLimitStateV3, type ModelFamily, type HeaderStyle, type CooldownReason } from "./storage"; import type { OAuthAuthDetails, RefreshParts } from "./types"; import type { AccountSelectionStrategy } from "./config/schema"; import { getHealthTracker, getTokenTracker, selectHybridAccount, type AccountWithMetrics } from "./rotation"; import { generateFingerprint, updateFingerprintVersion, type Fingerprint, type FingerprintVersion, MAX_FINGERPRINT_HISTORY } from "./fingerprint"; import type { QuotaGroup, QuotaGroupSummary } from "./quota"; import { getModelFamily } from "./transform/model-resolver"; import { debugLogToFile } from "./debug"; import { formatAccountLabel } from "./logging-utils"; export type { ModelFamily, HeaderStyle, CooldownReason } from "./storage"; export type { AccountSelectionStrategy } from "./config/schema"; export type RateLimitReason = | "QUOTA_EXHAUSTED" | "RATE_LIMIT_EXCEEDED" | "MODEL_CAPACITY_EXHAUSTED" | "SERVER_ERROR" | "UNKNOWN"; export interface RateLimitBackoffResult { backoffMs: number; reason: RateLimitReason; } const QUOTA_EXHAUSTED_BACKOFFS = [60_000, 300_000, 1_800_000, 7_200_000] as const; const RATE_LIMIT_EXCEEDED_BACKOFF = 30_000; // Increased from 15s to 45s base + jitter to reduce retry pressure on capacity errors const MODEL_CAPACITY_EXHAUSTED_BASE_BACKOFF = 45_000; const MODEL_CAPACITY_EXHAUSTED_JITTER_MAX = 30_000; // ±15s jitter range const SERVER_ERROR_BACKOFF = 20_000; const UNKNOWN_BACKOFF = 60_000; const MIN_BACKOFF_MS = 2_000; /** * Generate a random jitter value for backoff timing. * Helps prevent thundering herd problem when multiple clients retry simultaneously. */ function generateJitter(maxJitterMs: number): number { return Math.random() * maxJitterMs - (maxJitterMs / 2); } export function parseRateLimitReason( reason: string | undefined, message: string | undefined, status?: number ): RateLimitReason { // 1. Status Code Checks (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"; // 2. Explicit Reason String if (reason) { switch (reason.toUpperCase()) { case "QUOTA_EXHAUSTED": return "QUOTA_EXHAUSTED"; case "RATE_LIMIT_EXCEEDED": return "RATE_LIMIT_EXCEEDED"; case "MODEL_CAPACITY_EXHAUSTED": return "MODEL_CAPACITY_EXHAUSTED"; } } // 3. Message Text Scanning (Rust Regex parity) if (message) { const lower = message.toLowerCase(); // Capacity / Overloaded (Transient) - Check FIRST before "exhausted" if (lower.includes("capacity") || lower.includes("overloaded") || lower.includes("resource exhausted")) { return "MODEL_CAPACITY_EXHAUSTED"; } // RPM / TPM (Short Wait) // "per minute", "rate limit", "too many requests" // "presque" (French: almost) - retained for i18n parity with Rust reference if (lower.includes("per minute") || lower.includes("rate limit") || lower.includes("too many requests") || lower.includes("presque")) { return "RATE_LIMIT_EXCEEDED"; } // Quota (Long Wait) if (lower.includes("exhausted") || lower.includes("quota")) { return "QUOTA_EXHAUSTED"; } } // Default fallback for 429 without clearer info if (status === 429) { return "UNKNOWN"; } return "UNKNOWN"; } export function calculateBackoffMs( reason: RateLimitReason, consecutiveFailures: number, retryAfterMs?: number | null ): number { // Respect explicit Retry-After header if reasonable if (retryAfterMs && retryAfterMs > 0) { // Rust uses 2s min buffer, we keep 2s return Math.max(retryAfterMs, MIN_BACKOFF_MS); } switch (reason) { case "QUOTA_EXHAUSTED": { const index = Math.min(consecutiveFailures, QUOTA_EXHAUSTED_BACKOFFS.length - 1); return QUOTA_EXHAUSTED_BACKOFFS[index] ?? UNKNOWN_BACKOFF; } case "RATE_LIMIT_EXCEEDED": return RATE_LIMIT_EXCEEDED_BACKOFF; // 30s case "MODEL_CAPACITY_EXHAUSTED": // Apply jitter to prevent thundering herd on capacity errors return MODEL_CAPACITY_EXHAUSTED_BASE_BACKOFF + generateJitter(MODEL_CAPACITY_EXHAUSTED_JITTER_MAX); case "SERVER_ERROR": return SERVER_ERROR_BACKOFF; // 20s case "UNKNOWN": default: return UNKNOWN_BACKOFF; // 60s } } export type BaseQuotaKey = "claude" | "gemini-antigravity" | "gemini-cli"; export type QuotaKey = BaseQuotaKey | `${BaseQuotaKey}:${string}`; export interface ManagedAccount { index: number; email?: string; addedAt: number; lastUsed: number; parts: RefreshParts; access?: string; expires?: number; enabled: boolean; rateLimitResetTimes: RateLimitStateV3; lastSwitchReason?: "rate-limit" | "initial" | "rotation"; coolingDownUntil?: number; cooldownReason?: CooldownReason; touchedForQuota: Record; consecutiveFailures?: number; /** Timestamp of last failure for TTL-based reset of consecutiveFailures */ lastFailureTime?: number; /** Per-account device fingerprint for rate limit mitigation */ fingerprint?: import("./fingerprint").Fingerprint; /** History of previous fingerprints for this account */ fingerprintHistory?: FingerprintVersion[]; /** Cached quota data from last checkAccountsQuota() call */ cachedQuota?: Partial>; cachedQuotaUpdatedAt?: number; verificationRequired?: boolean; verificationRequiredAt?: number; verificationRequiredReason?: string; verificationUrl?: string; } function nowMs(): number { return Date.now(); } function clampNonNegativeInt(value: unknown, fallback: number): number { if (typeof value !== "number" || !Number.isFinite(value)) { return fallback; } return value < 0 ? 0 : Math.floor(value); } function getQuotaKey(family: ModelFamily, headerStyle: HeaderStyle, model?: string | null): QuotaKey { if (family === "claude") { return "claude"; } const base = headerStyle === "gemini-cli" ? "gemini-cli" : "gemini-antigravity"; if (model) { return `${base}:${model}`; } return base; } function isRateLimitedForQuotaKey(account: ManagedAccount, key: QuotaKey): boolean { const resetTime = account.rateLimitResetTimes[key]; return resetTime !== undefined && nowMs() < resetTime; } function isRateLimitedForFamily(account: ManagedAccount, family: ModelFamily, model?: string | null): boolean { if (family === "claude") { return isRateLimitedForQuotaKey(account, "claude"); } const antigravityIsLimited = isRateLimitedForHeaderStyle(account, family, "antigravity", model); const cliIsLimited = isRateLimitedForHeaderStyle(account, family, "gemini-cli", model); return antigravityIsLimited && cliIsLimited; } function isRateLimitedForHeaderStyle(account: ManagedAccount, family: ModelFamily, headerStyle: HeaderStyle, model?: string | null): boolean { clearExpiredRateLimits(account); if (family === "claude") { return isRateLimitedForQuotaKey(account, "claude"); } // Check model-specific quota first if provided if (model) { const modelKey = getQuotaKey(family, headerStyle, model); if (isRateLimitedForQuotaKey(account, modelKey)) { return true; } } // Then check base family quota const baseKey = getQuotaKey(family, headerStyle); return isRateLimitedForQuotaKey(account, baseKey); } function clearExpiredRateLimits(account: ManagedAccount): void { const now = nowMs(); const keys = Object.keys(account.rateLimitResetTimes) as QuotaKey[]; for (const key of keys) { const resetTime = account.rateLimitResetTimes[key]; if (resetTime !== undefined && now >= resetTime) { delete account.rateLimitResetTimes[key]; } } } /** * Resolve the quota group for soft quota checks. * * When a model string is available, we can precisely determine the quota group. * When model is null/undefined, we fall back based on family: * - Claude → "claude" quota group * - Gemini → "gemini-pro" (conservative fallback; may misclassify flash models) * * @param family - The model family ("claude" | "gemini") * @param model - Optional model string for precise resolution * @returns The QuotaGroup to use for soft quota checks */ export function resolveQuotaGroup(family: ModelFamily, model?: string | null): QuotaGroup { if (model) { return getModelFamily(model); } return family === "claude" ? "claude" : "gemini-pro"; } function isOverSoftQuotaThreshold( account: ManagedAccount, family: ModelFamily, thresholdPercent: number, cacheTtlMs: number, model?: string | null ): boolean { if (thresholdPercent >= 100) return false; if (!account.cachedQuota) return false; if (account.cachedQuotaUpdatedAt == null) return false; const age = nowMs() - account.cachedQuotaUpdatedAt; if (age > cacheTtlMs) return false; const quotaGroup = resolveQuotaGroup(family, model); const groupData = account.cachedQuota[quotaGroup]; if (groupData?.remainingFraction == null) return false; const remainingFraction = Math.max(0, Math.min(1, groupData.remainingFraction)); const usedPercent = (1 - remainingFraction) * 100; const isOverThreshold = usedPercent >= thresholdPercent; if (isOverThreshold) { const accountLabel = formatAccountLabel(account.email, account.index); const resetSuffix = groupData.resetTime ? ` (resets: ${groupData.resetTime})` : ""; const message = `[SoftQuota] Skipping ${accountLabel}: ${quotaGroup} usage ${usedPercent.toFixed(1)}% >= threshold ${thresholdPercent}%${resetSuffix}`; debugLogToFile(message); } return isOverThreshold; } export function computeSoftQuotaCacheTtlMs( ttlConfig: "auto" | number, refreshIntervalMinutes: number ): number { if (ttlConfig === "auto") { return Math.max(2 * refreshIntervalMinutes, 10) * 60 * 1000; } return ttlConfig * 60 * 1000; } /** * In-memory multi-account manager with sticky account selection. * * Uses the same account until it hits a rate limit (429), then switches. * Rate limits are tracked per-model-family (claude/gemini) so an account * rate-limited for Claude can still be used for Gemini. * * Source of truth for the pool is `antigravity-accounts.json`. */ export class AccountManager { private accounts: ManagedAccount[] = []; private cursor = 0; private currentAccountIndexByFamily: Record = { claude: -1, gemini: -1, }; private sessionOffsetApplied: Record = { claude: false, gemini: false, }; private lastToastAccountIndex = -1; private lastToastTime = 0; private savePending = false; private saveTimeout: ReturnType | null = null; private savePromiseResolvers: Array<() => void> = []; static async loadFromDisk(authFallback?: OAuthAuthDetails): Promise { const stored = await loadAccounts(); return new AccountManager(authFallback, stored); } constructor(authFallback?: OAuthAuthDetails, stored?: AccountStorageV4 | null) { const authParts = authFallback ? parseRefreshParts(authFallback.refresh) : null; if (stored && stored.accounts.length === 0) { this.accounts = []; this.cursor = 0; return; } if (stored && stored.accounts.length > 0) { const baseNow = nowMs(); this.accounts = stored.accounts .map((acc, index): ManagedAccount | null => { if (!acc.refreshToken || typeof acc.refreshToken !== "string") { return null; } const matchesFallback = !!( authFallback && authParts && authParts.refreshToken && acc.refreshToken === authParts.refreshToken ); return { index, email: acc.email, addedAt: clampNonNegativeInt(acc.addedAt, baseNow), lastUsed: clampNonNegativeInt(acc.lastUsed, 0), parts: { refreshToken: acc.refreshToken, projectId: acc.projectId, managedProjectId: acc.managedProjectId, }, access: matchesFallback ? authFallback?.access : undefined, expires: matchesFallback ? authFallback?.expires : undefined, enabled: acc.enabled !== false, rateLimitResetTimes: acc.rateLimitResetTimes ?? {}, lastSwitchReason: acc.lastSwitchReason, coolingDownUntil: acc.coolingDownUntil, cooldownReason: acc.cooldownReason, touchedForQuota: {}, fingerprint: acc.fingerprint ?? generateFingerprint(), fingerprintHistory: acc.fingerprintHistory ?? [], cachedQuota: acc.cachedQuota as Partial> | undefined, cachedQuotaUpdatedAt: acc.cachedQuotaUpdatedAt, verificationRequired: acc.verificationRequired, verificationRequiredAt: acc.verificationRequiredAt, verificationRequiredReason: acc.verificationRequiredReason, verificationUrl: acc.verificationUrl, }; }) .filter((a): a is ManagedAccount => a !== null); // Update fingerprint versions to match the current runtime version. // Saved fingerprints may carry an older version string; this ensures // they always reflect the latest fetched (or fallback) version. let fingerprintVersionChanged = false; for (const acc of this.accounts) { if (acc.fingerprint && updateFingerprintVersion(acc.fingerprint)) { fingerprintVersionChanged = true; } } this.cursor = clampNonNegativeInt(stored.activeIndex, 0); if (this.accounts.length > 0) { this.cursor = this.cursor % this.accounts.length; const defaultIndex = this.cursor; this.currentAccountIndexByFamily.claude = clampNonNegativeInt( stored.activeIndexByFamily?.claude, defaultIndex ) % this.accounts.length; this.currentAccountIndexByFamily.gemini = clampNonNegativeInt( stored.activeIndexByFamily?.gemini, defaultIndex ) % this.accounts.length; } // Persist updated fingerprint versions to disk if (fingerprintVersionChanged) { this.requestSaveToDisk(); } return; } // If we have stored accounts, check if we need to add the current auth if (authFallback && this.accounts.length > 0) { const authParts = parseRefreshParts(authFallback.refresh); const hasMatching = this.accounts.some(acc => acc.parts.refreshToken === authParts.refreshToken); if (!hasMatching && authParts.refreshToken) { const now = nowMs(); const newAccount: ManagedAccount = { index: this.accounts.length, email: undefined, addedAt: now, lastUsed: 0, parts: authParts, access: authFallback.access, expires: authFallback.expires, enabled: true, rateLimitResetTimes: {}, touchedForQuota: {}, }; this.accounts.push(newAccount); // Update indices to include the new account this.currentAccountIndexByFamily.claude = Math.min(this.currentAccountIndexByFamily.claude, this.accounts.length - 1); this.currentAccountIndexByFamily.gemini = Math.min(this.currentAccountIndexByFamily.gemini, this.accounts.length - 1); } } if (authFallback) { const parts = parseRefreshParts(authFallback.refresh); if (parts.refreshToken) { const now = nowMs(); this.accounts = [ { index: 0, email: undefined, addedAt: now, lastUsed: 0, parts, access: authFallback.access, expires: authFallback.expires, enabled: true, rateLimitResetTimes: {}, touchedForQuota: {}, }, ]; this.cursor = 0; this.currentAccountIndexByFamily.claude = 0; this.currentAccountIndexByFamily.gemini = 0; } } } getAccountCount(): number { return this.getEnabledAccounts().length; } getTotalAccountCount(): number { return this.accounts.length; } getEnabledAccounts(): ManagedAccount[] { return this.accounts.filter((account) => account.enabled !== false); } getAccountsSnapshot(): ManagedAccount[] { return this.accounts.map((a) => ({ ...a, parts: { ...a.parts }, rateLimitResetTimes: { ...a.rateLimitResetTimes } })); } getCurrentAccountForFamily(family: ModelFamily): ManagedAccount | null { const currentIndex = this.currentAccountIndexByFamily[family]; if (currentIndex >= 0 && currentIndex < this.accounts.length) { const account = this.accounts[currentIndex] ?? null; // Only return account if it's enabled - disabled accounts should not be selected if (account && account.enabled !== false) { return account; } } return null; } markSwitched(account: ManagedAccount, reason: "rate-limit" | "initial" | "rotation", family: ModelFamily): void { account.lastSwitchReason = reason; this.currentAccountIndexByFamily[family] = account.index; } /** * Check if we should show an account switch toast. * Debounces repeated toasts for the same account. */ shouldShowAccountToast(accountIndex: number, debounceMs = 30000): boolean { const now = nowMs(); if (accountIndex !== this.lastToastAccountIndex) { return true; } return now - this.lastToastTime >= debounceMs; } markToastShown(accountIndex: number): void { this.lastToastAccountIndex = accountIndex; this.lastToastTime = nowMs(); } getCurrentOrNextForFamily( family: ModelFamily, model?: string | null, strategy: AccountSelectionStrategy = 'sticky', headerStyle: HeaderStyle = 'antigravity', pidOffsetEnabled: boolean = false, softQuotaThresholdPercent: number = 100, softQuotaCacheTtlMs: number = 10 * 60 * 1000, ): ManagedAccount | null { const quotaKey = getQuotaKey(family, headerStyle, model); if (strategy === 'round-robin') { const next = this.getNextForFamily(family, model, headerStyle, softQuotaThresholdPercent, softQuotaCacheTtlMs); if (next) { this.markTouchedForQuota(next, quotaKey); this.currentAccountIndexByFamily[family] = next.index; } return next; } if (strategy === 'hybrid') { const healthTracker = getHealthTracker(); const tokenTracker = getTokenTracker(); const accountsWithMetrics: AccountWithMetrics[] = this.accounts .filter(acc => acc.enabled !== false) .map(acc => { clearExpiredRateLimits(acc); return { index: acc.index, lastUsed: acc.lastUsed, healthScore: healthTracker.getScore(acc.index), isRateLimited: isRateLimitedForFamily(acc, family, model) || isOverSoftQuotaThreshold(acc, family, softQuotaThresholdPercent, softQuotaCacheTtlMs, model), isCoolingDown: this.isAccountCoolingDown(acc), }; }); // Get current account index for stickiness const currentIndex = this.currentAccountIndexByFamily[family] ?? null; const selectedIndex = selectHybridAccount(accountsWithMetrics, tokenTracker, currentIndex); if (selectedIndex !== null) { const selected = this.accounts[selectedIndex]; if (selected) { selected.lastUsed = nowMs(); this.markTouchedForQuota(selected, quotaKey); this.currentAccountIndexByFamily[family] = selected.index; return selected; } } } // Fallback: sticky selection (used when hybrid finds no candidates) // PID-based offset for multi-session distribution (opt-in) // Different sessions (PIDs) will prefer different starting accounts if (pidOffsetEnabled && !this.sessionOffsetApplied[family] && this.accounts.length > 1) { const pidOffset = process.pid % this.accounts.length; const baseIndex = this.currentAccountIndexByFamily[family] ?? 0; const newIndex = (baseIndex + pidOffset) % this.accounts.length; debugLogToFile(`[Account] Applying PID offset: pid=${process.pid} offset=${pidOffset} family=${family} index=${baseIndex}->${newIndex}`); this.currentAccountIndexByFamily[family] = newIndex; this.sessionOffsetApplied[family] = true; } const current = this.getCurrentAccountForFamily(family); if (current) { clearExpiredRateLimits(current); const isLimitedForRequestedStyle = isRateLimitedForHeaderStyle(current, family, headerStyle, model); const isOverThreshold = isOverSoftQuotaThreshold(current, family, softQuotaThresholdPercent, softQuotaCacheTtlMs, model); if (!isLimitedForRequestedStyle && !isOverThreshold && !this.isAccountCoolingDown(current)) { this.markTouchedForQuota(current, quotaKey); return current; } } const next = this.getNextForFamily(family, model, headerStyle, softQuotaThresholdPercent, softQuotaCacheTtlMs); if (next) { this.markTouchedForQuota(next, quotaKey); this.currentAccountIndexByFamily[family] = next.index; } return next; } getNextForFamily(family: ModelFamily, model?: string | null, headerStyle: HeaderStyle = "antigravity", softQuotaThresholdPercent: number = 100, softQuotaCacheTtlMs: number = 10 * 60 * 1000): ManagedAccount | null { const available = this.accounts.filter((a) => { clearExpiredRateLimits(a); return a.enabled !== false && !isRateLimitedForHeaderStyle(a, family, headerStyle, model) && !isOverSoftQuotaThreshold(a, family, softQuotaThresholdPercent, softQuotaCacheTtlMs, model) && !this.isAccountCoolingDown(a); }); if (available.length === 0) { return null; } const account = available[this.cursor % available.length]; if (!account) { return null; } this.cursor++; // Note: lastUsed is now updated after successful request via markAccountUsed() return account; } markRateLimited( account: ManagedAccount, retryAfterMs: number, family: ModelFamily, headerStyle: HeaderStyle = "antigravity", model?: string | null ): void { const key = getQuotaKey(family, headerStyle, model); account.rateLimitResetTimes[key] = nowMs() + retryAfterMs; } /** * Mark an account as used after a successful API request. * This updates the lastUsed timestamp for freshness calculations. * Should be called AFTER request completion, not during account selection. */ markAccountUsed(accountIndex: number): void { const account = this.accounts.find(a => a.index === accountIndex); if (account) { account.lastUsed = nowMs(); } } markRateLimitedWithReason( account: ManagedAccount, family: ModelFamily, headerStyle: HeaderStyle, model: string | null | undefined, reason: RateLimitReason, retryAfterMs?: number | null, failureTtlMs: number = 3600_000, // Default 1 hour TTL ): number { const now = nowMs(); // TTL-based reset: if last failure was more than failureTtlMs ago, reset count if (account.lastFailureTime !== undefined && (now - account.lastFailureTime) > failureTtlMs) { account.consecutiveFailures = 0; } const failures = (account.consecutiveFailures ?? 0) + 1; account.consecutiveFailures = failures; account.lastFailureTime = now; const backoffMs = calculateBackoffMs(reason, failures - 1, retryAfterMs); const key = getQuotaKey(family, headerStyle, model); account.rateLimitResetTimes[key] = now + backoffMs; return backoffMs; } markRequestSuccess(account: ManagedAccount): void { if (account.consecutiveFailures) { account.consecutiveFailures = 0; } } clearAllRateLimitsForFamily(family: ModelFamily, model?: string | null): void { for (const account of this.accounts) { if (family === "claude") { delete account.rateLimitResetTimes.claude; } else { const antigravityKey = getQuotaKey(family, "antigravity", model); const cliKey = getQuotaKey(family, "gemini-cli", model); delete account.rateLimitResetTimes[antigravityKey]; delete account.rateLimitResetTimes[cliKey]; } account.consecutiveFailures = 0; } } shouldTryOptimisticReset(family: ModelFamily, model?: string | null): boolean { const minWaitMs = this.getMinWaitTimeForFamily(family, model); return minWaitMs > 0 && minWaitMs <= 2_000; } markAccountCoolingDown(account: ManagedAccount, cooldownMs: number, reason: CooldownReason): void { account.coolingDownUntil = nowMs() + cooldownMs; account.cooldownReason = reason; } isAccountCoolingDown(account: ManagedAccount): boolean { if (account.coolingDownUntil === undefined) { return false; } if (nowMs() >= account.coolingDownUntil) { this.clearAccountCooldown(account); return false; } return true; } clearAccountCooldown(account: ManagedAccount): void { delete account.coolingDownUntil; delete account.cooldownReason; } getAccountCooldownReason(account: ManagedAccount): CooldownReason | undefined { return this.isAccountCoolingDown(account) ? account.cooldownReason : undefined; } markTouchedForQuota(account: ManagedAccount, quotaKey: string): void { account.touchedForQuota[quotaKey] = nowMs(); } isFreshForQuota(account: ManagedAccount, quotaKey: string): boolean { const touchedAt = account.touchedForQuota[quotaKey]; if (!touchedAt) return true; const resetTime = account.rateLimitResetTimes[quotaKey as QuotaKey]; if (resetTime && touchedAt < resetTime) return true; return false; } getFreshAccountsForQuota(quotaKey: string, family: ModelFamily, model?: string | null): ManagedAccount[] { return this.accounts.filter(acc => { clearExpiredRateLimits(acc); return acc.enabled !== false && this.isFreshForQuota(acc, quotaKey) && !isRateLimitedForFamily(acc, family, model) && !this.isAccountCoolingDown(acc); }); } isRateLimitedForHeaderStyle( account: ManagedAccount, family: ModelFamily, headerStyle: HeaderStyle, model?: string | null ): boolean { return isRateLimitedForHeaderStyle(account, family, headerStyle, model); } getAvailableHeaderStyle(account: ManagedAccount, family: ModelFamily, model?: string | null): HeaderStyle | null { clearExpiredRateLimits(account); if (family === "claude") { return isRateLimitedForHeaderStyle(account, family, "antigravity") ? null : "antigravity"; } if (!isRateLimitedForHeaderStyle(account, family, "antigravity", model)) { return "antigravity"; } if (!isRateLimitedForHeaderStyle(account, family, "gemini-cli", model)) { return "gemini-cli"; } return null; } /** * Check if any OTHER account has antigravity quota available for the given family/model. * * Used to determine whether to switch accounts vs fall back to gemini-cli: * - If true: Switch to another account (preserve antigravity priority) * - If false: All accounts exhausted antigravity, safe to fall back to gemini-cli * * @param currentAccountIndex - Index of the current account (will be excluded from check) * @param family - Model family ("gemini" or "claude") * @param model - Optional model name for model-specific rate limits * @returns true if any other enabled, non-cooling-down account has antigravity available */ hasOtherAccountWithAntigravityAvailable( currentAccountIndex: number, family: ModelFamily, model?: string | null ): boolean { // Claude has no gemini-cli fallback - always return false // (This method is only relevant for Gemini's dual quota pools) if (family === "claude") { return false; } return this.accounts.some(acc => { // Skip current account if (acc.index === currentAccountIndex) { return false; } // Skip disabled accounts if (acc.enabled === false) { return false; } // Skip cooling down accounts if (this.isAccountCoolingDown(acc)) { return false; } // Clear expired rate limits before checking clearExpiredRateLimits(acc); // Check if antigravity is available for this account return !isRateLimitedForHeaderStyle(acc, family, "antigravity", model); }); } setAccountEnabled(accountIndex: number, enabled: boolean): boolean { const account = this.accounts[accountIndex]; if (!account) { return false; } account.enabled = enabled; if (!enabled) { for (const family of Object.keys(this.currentAccountIndexByFamily) as ModelFamily[]) { if (this.currentAccountIndexByFamily[family] === accountIndex) { const next = this.accounts.find((a, i) => i !== accountIndex && a.enabled !== false); this.currentAccountIndexByFamily[family] = next?.index ?? -1; } } } this.requestSaveToDisk(); return true; } markAccountVerificationRequired(accountIndex: number, reason?: string, verifyUrl?: string): boolean { const account = this.accounts[accountIndex]; if (!account) { return false; } account.verificationRequired = true; account.verificationRequiredAt = nowMs(); account.verificationRequiredReason = reason?.trim() || undefined; const normalizedVerifyUrl = verifyUrl?.trim(); if (normalizedVerifyUrl) { account.verificationUrl = normalizedVerifyUrl; } if (account.enabled !== false) { this.setAccountEnabled(accountIndex, false); } else { this.requestSaveToDisk(); } return true; } clearAccountVerificationRequired(accountIndex: number, enableAccount = false): boolean { const account = this.accounts[accountIndex]; if (!account) { return false; } const wasVerificationRequired = account.verificationRequired === true; const hadMetadata = ( account.verificationRequiredAt !== undefined || account.verificationRequiredReason !== undefined || account.verificationUrl !== undefined ); account.verificationRequired = false; account.verificationRequiredAt = undefined; account.verificationRequiredReason = undefined; account.verificationUrl = undefined; if (enableAccount && wasVerificationRequired && account.enabled === false) { this.setAccountEnabled(accountIndex, true); } else if (wasVerificationRequired || hadMetadata) { this.requestSaveToDisk(); } return true; } removeAccountByIndex(accountIndex: number): boolean { if (accountIndex < 0 || accountIndex >= this.accounts.length) { return false; } const account = this.accounts[accountIndex]; if (!account) { return false; } return this.removeAccount(account); } removeAccount(account: ManagedAccount): boolean { const idx = this.accounts.indexOf(account); if (idx < 0) { return false; } this.accounts.splice(idx, 1); this.accounts.forEach((acc, index) => { acc.index = index; }); if (this.accounts.length === 0) { this.cursor = 0; this.currentAccountIndexByFamily.claude = -1; this.currentAccountIndexByFamily.gemini = -1; return true; } if (this.cursor > idx) { this.cursor -= 1; } this.cursor = this.cursor % this.accounts.length; for (const family of ["claude", "gemini"] as ModelFamily[]) { if (this.currentAccountIndexByFamily[family] > idx) { this.currentAccountIndexByFamily[family] -= 1; } if (this.currentAccountIndexByFamily[family] >= this.accounts.length) { this.currentAccountIndexByFamily[family] = -1; } } return true; } updateFromAuth(account: ManagedAccount, auth: OAuthAuthDetails): void { const parts = parseRefreshParts(auth.refresh); // Preserve existing projectId/managedProjectId if not in the new parts account.parts = { ...parts, projectId: parts.projectId ?? account.parts.projectId, managedProjectId: parts.managedProjectId ?? account.parts.managedProjectId, }; account.access = auth.access; account.expires = auth.expires; } toAuthDetails(account: ManagedAccount): OAuthAuthDetails { return { type: "oauth", refresh: formatRefreshParts(account.parts), access: account.access, expires: account.expires, }; } getMinWaitTimeForFamily( family: ModelFamily, model?: string | null, headerStyle?: HeaderStyle, strict?: boolean, ): number { const available = this.accounts.filter((a) => { clearExpiredRateLimits(a); return a.enabled !== false && (strict && headerStyle ? !isRateLimitedForHeaderStyle(a, family, headerStyle, model) : !isRateLimitedForFamily(a, family, model)); }); if (available.length > 0) { return 0; } const waitTimes: number[] = []; for (const a of this.accounts) { if (family === "claude") { const t = a.rateLimitResetTimes.claude; if (t !== undefined) waitTimes.push(Math.max(0, t - nowMs())); } else if (strict && headerStyle) { const key = getQuotaKey(family, headerStyle, model); const t = a.rateLimitResetTimes[key]; if (t !== undefined) waitTimes.push(Math.max(0, t - nowMs())); } else { // For Gemini, account becomes available when EITHER pool expires for this model/family const antigravityKey = getQuotaKey(family, "antigravity", model); const cliKey = getQuotaKey(family, "gemini-cli", model); const t1 = a.rateLimitResetTimes[antigravityKey]; const t2 = a.rateLimitResetTimes[cliKey]; const accountWait = Math.min( t1 !== undefined ? Math.max(0, t1 - nowMs()) : Infinity, t2 !== undefined ? Math.max(0, t2 - nowMs()) : Infinity ); if (accountWait !== Infinity) waitTimes.push(accountWait); } } return waitTimes.length > 0 ? Math.min(...waitTimes) : 0; } getAccounts(): ManagedAccount[] { return [...this.accounts]; } async saveToDisk(): Promise { const claudeIndex = Math.max(0, this.currentAccountIndexByFamily.claude); const geminiIndex = Math.max(0, this.currentAccountIndexByFamily.gemini); const storage: AccountStorageV4 = { version: 4, accounts: this.accounts.map((a) => ({ email: a.email, refreshToken: a.parts.refreshToken, projectId: a.parts.projectId, managedProjectId: a.parts.managedProjectId, addedAt: a.addedAt, lastUsed: a.lastUsed, enabled: a.enabled, lastSwitchReason: a.lastSwitchReason, rateLimitResetTimes: Object.keys(a.rateLimitResetTimes).length > 0 ? a.rateLimitResetTimes : undefined, coolingDownUntil: a.coolingDownUntil, cooldownReason: a.cooldownReason, fingerprint: a.fingerprint, fingerprintHistory: a.fingerprintHistory?.length ? a.fingerprintHistory : undefined, cachedQuota: a.cachedQuota && Object.keys(a.cachedQuota).length > 0 ? a.cachedQuota : undefined, cachedQuotaUpdatedAt: a.cachedQuotaUpdatedAt, verificationRequired: a.verificationRequired, verificationRequiredAt: a.verificationRequiredAt, verificationRequiredReason: a.verificationRequiredReason, verificationUrl: a.verificationUrl, })), activeIndex: claudeIndex, activeIndexByFamily: { claude: claudeIndex, gemini: geminiIndex, }, }; await saveAccounts(storage); } requestSaveToDisk(): void { if (this.savePending) { return; } this.savePending = true; this.saveTimeout = setTimeout(() => { void this.executeSave(); }, 1000); } async flushSaveToDisk(): Promise { if (!this.savePending) { return; } return new Promise((resolve) => { this.savePromiseResolvers.push(resolve); }); } private async executeSave(): Promise { this.savePending = false; this.saveTimeout = null; try { await this.saveToDisk(); } catch { // best-effort persistence; avoid unhandled rejection from timer-driven saves } finally { const resolvers = this.savePromiseResolvers; this.savePromiseResolvers = []; for (const resolve of resolvers) { resolve(); } } } // ========== Fingerprint Management ========== /** * Regenerate fingerprint for an account, saving the old one to history. * @param accountIndex - Index of the account to regenerate fingerprint for * @returns The new fingerprint, or null if account not found */ regenerateAccountFingerprint(accountIndex: number): Fingerprint | null { const account = this.accounts[accountIndex]; if (!account) return null; // Save current fingerprint to history if it exists if (account.fingerprint) { const historyEntry: FingerprintVersion = { fingerprint: account.fingerprint, timestamp: nowMs(), reason: 'regenerated', }; if (!account.fingerprintHistory) { account.fingerprintHistory = []; } // Add to beginning of history (most recent first) account.fingerprintHistory.unshift(historyEntry); // Trim to max history size if (account.fingerprintHistory.length > MAX_FINGERPRINT_HISTORY) { account.fingerprintHistory = account.fingerprintHistory.slice(0, MAX_FINGERPRINT_HISTORY); } } // Generate and assign new fingerprint account.fingerprint = generateFingerprint(); this.requestSaveToDisk(); return account.fingerprint; } /** * Restore a fingerprint from history for an account. * @param accountIndex - Index of the account * @param historyIndex - Index in the fingerprint history to restore from (0 = most recent) * @returns The restored fingerprint, or null if account/history not found */ restoreAccountFingerprint(accountIndex: number, historyIndex: number): Fingerprint | null { const account = this.accounts[accountIndex]; if (!account) return null; const history = account.fingerprintHistory; if (!history || historyIndex < 0 || historyIndex >= history.length) { return null; } // Capture the fingerprint to restore BEFORE modifying history const fingerprintToRestore = history[historyIndex]!.fingerprint; // Save current fingerprint to history before restoring (if it exists) if (account.fingerprint) { const historyEntry: FingerprintVersion = { fingerprint: account.fingerprint, timestamp: nowMs(), reason: 'restored', }; account.fingerprintHistory!.unshift(historyEntry); // Trim to max history size if (account.fingerprintHistory!.length > MAX_FINGERPRINT_HISTORY) { account.fingerprintHistory = account.fingerprintHistory!.slice(0, MAX_FINGERPRINT_HISTORY); } } // Restore the fingerprint account.fingerprint = { ...fingerprintToRestore, createdAt: nowMs() }; this.requestSaveToDisk(); return account.fingerprint; } /** * Get fingerprint history for an account. * @param accountIndex - Index of the account * @returns Array of fingerprint versions, or empty array if not found */ getAccountFingerprintHistory(accountIndex: number): FingerprintVersion[] { const account = this.accounts[accountIndex]; if (!account || !account.fingerprintHistory) { return []; } return [...account.fingerprintHistory]; } updateQuotaCache(accountIndex: number, quotaGroups: Partial>): void { const account = this.accounts[accountIndex]; if (account) { account.cachedQuota = quotaGroups; account.cachedQuotaUpdatedAt = nowMs(); } } isAccountOverSoftQuota(account: ManagedAccount, family: ModelFamily, thresholdPercent: number, cacheTtlMs: number, model?: string | null): boolean { return isOverSoftQuotaThreshold(account, family, thresholdPercent, cacheTtlMs, model); } getAccountsForQuotaCheck(): AccountMetadataV3[] { return this.accounts.map((a) => ({ email: a.email, refreshToken: a.parts.refreshToken, projectId: a.parts.projectId, managedProjectId: a.parts.managedProjectId, addedAt: a.addedAt, lastUsed: a.lastUsed, enabled: a.enabled, })); } getOldestQuotaCacheAge(): number | null { let oldest: number | null = null; for (const acc of this.accounts) { if (acc.enabled === false) continue; if (acc.cachedQuotaUpdatedAt == null) return null; const age = nowMs() - acc.cachedQuotaUpdatedAt; if (oldest === null || age > oldest) oldest = age; } return oldest; } areAllAccountsOverSoftQuota(family: ModelFamily, thresholdPercent: number, cacheTtlMs: number, model?: string | null): boolean { if (thresholdPercent >= 100) return false; const enabled = this.accounts.filter(a => a.enabled !== false); if (enabled.length === 0) return false; return enabled.every(a => isOverSoftQuotaThreshold(a, family, thresholdPercent, cacheTtlMs, model)); } /** * Get minimum wait time until any account's soft quota resets. * Returns 0 if any account is available (not over threshold). * Returns the minimum resetTime across all over-threshold accounts. * Returns null if no resetTime data is available. */ getMinWaitTimeForSoftQuota( family: ModelFamily, thresholdPercent: number, cacheTtlMs: number, model?: string | null ): number | null { if (thresholdPercent >= 100) return 0; const enabled = this.accounts.filter(a => a.enabled !== false); if (enabled.length === 0) return null; // If any account is available (not over threshold), no wait needed const available = enabled.filter(a => !isOverSoftQuotaThreshold(a, family, thresholdPercent, cacheTtlMs, model)); if (available.length > 0) return 0; // All accounts are over threshold - find earliest reset time // For gemini family, we MUST have the model to distinguish pro vs flash quotas. // Fail-open (return null = no wait info) if model is missing to avoid blocking on wrong quota. if (!model && family !== "claude") return null; const quotaGroup = resolveQuotaGroup(family, model); const now = nowMs(); const waitTimes: number[] = []; for (const acc of enabled) { const groupData = acc.cachedQuota?.[quotaGroup]; if (groupData?.resetTime) { const resetTimestamp = Date.parse(groupData.resetTime); if (Number.isFinite(resetTimestamp)) { waitTimes.push(Math.max(0, resetTimestamp - now)); } } } if (waitTimes.length === 0) return null; const minWait = Math.min(...waitTimes); // Treat 0 as stale cache (resetTime in the past) → fail-open to avoid spin loop return minWait === 0 ? null : minWait; } } ================================================ FILE: src/plugin/antigravity-first-fallback.test.ts ================================================ import { beforeEach, describe, expect, it, vi } from "vitest"; import { AccountManager, type ModelFamily, type HeaderStyle } from "./accounts"; import type { AccountStorageV4 } from "./storage"; /** * Test: Antigravity-first fallback logic * * Requirement: Exhaust Antigravity across ALL accounts before falling back to Gemini CLI * * Scenario: * - Account 0: antigravity rate-limited, gemini-cli available * - Account 1: antigravity available * * Expected: Switch to Account 1 (use antigravity), NOT fall back to gemini-cli on Account 0 */ describe("Antigravity-first fallback", () => { beforeEach(() => { vi.useRealTimers(); }); describe("hasOtherAccountWithAntigravityAvailable", () => { it("returns true when another account has antigravity available", () => { const stored: AccountStorageV4 = { version: 4, accounts: [ { refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 }, { refreshToken: "r2", projectId: "p2", addedAt: 1, lastUsed: 0 }, ], activeIndex: 0, }; const manager = new AccountManager(undefined, stored); const accounts = manager.getAccounts(); // Mark account 0's antigravity as rate-limited manager.markRateLimited(accounts[0]!, 60000, "gemini", "antigravity"); // Account 1 should have antigravity available const hasOther = manager.hasOtherAccountWithAntigravityAvailable( accounts[0]!.index, "gemini", null ); expect(hasOther).toBe(true); }); it("returns false when all other accounts are also rate-limited for antigravity", () => { const stored: AccountStorageV4 = { version: 4, accounts: [ { refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 }, { refreshToken: "r2", projectId: "p2", addedAt: 1, lastUsed: 0 }, ], activeIndex: 0, }; const manager = new AccountManager(undefined, stored); const accounts = manager.getAccounts(); // Mark both accounts' antigravity as rate-limited manager.markRateLimited(accounts[0]!, 60000, "gemini", "antigravity"); manager.markRateLimited(accounts[1]!, 60000, "gemini", "antigravity"); const hasOther = manager.hasOtherAccountWithAntigravityAvailable( accounts[0]!.index, "gemini", null ); expect(hasOther).toBe(false); }); it("skips disabled accounts", () => { const stored: AccountStorageV4 = { version: 4, accounts: [ { refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 }, { refreshToken: "r2", projectId: "p2", addedAt: 1, lastUsed: 0, enabled: false }, ], activeIndex: 0, }; const manager = new AccountManager(undefined, stored); const accounts = manager.getAccounts(); // Mark account 0's antigravity as rate-limited manager.markRateLimited(accounts[0]!, 60000, "gemini", "antigravity"); // Account 1 is disabled, so should return false const hasOther = manager.hasOtherAccountWithAntigravityAvailable( accounts[0]!.index, "gemini", null ); expect(hasOther).toBe(false); }); it("skips cooling down accounts", () => { const stored: AccountStorageV4 = { version: 4, accounts: [ { refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 }, { refreshToken: "r2", projectId: "p2", addedAt: 1, lastUsed: 0 }, ], activeIndex: 0, }; const manager = new AccountManager(undefined, stored); const accounts = manager.getAccounts(); // Mark account 0's antigravity as rate-limited manager.markRateLimited(accounts[0]!, 60000, "gemini", "antigravity"); // Mark account 1 as cooling down manager.markAccountCoolingDown(accounts[1]!, 60000, "auth-failure"); const hasOther = manager.hasOtherAccountWithAntigravityAvailable( accounts[0]!.index, "gemini", null ); expect(hasOther).toBe(false); }); it("works with model-specific rate limits", () => { const stored: AccountStorageV4 = { version: 4, accounts: [ { refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 }, { refreshToken: "r2", projectId: "p2", addedAt: 1, lastUsed: 0 }, ], activeIndex: 0, }; const manager = new AccountManager(undefined, stored); const accounts = manager.getAccounts(); // Mark account 0's antigravity as rate-limited for gemini-3-pro manager.markRateLimited(accounts[0]!, 60000, "gemini", "antigravity", "gemini-3-pro"); // Account 1 should have antigravity available for gemini-3-pro const hasOther = manager.hasOtherAccountWithAntigravityAvailable( accounts[0]!.index, "gemini", "gemini-3-pro" ); expect(hasOther).toBe(true); }); it("returns false for Claude family (no gemini-cli fallback)", () => { const stored: AccountStorageV4 = { version: 4, accounts: [ { refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 }, { refreshToken: "r2", projectId: "p2", addedAt: 1, lastUsed: 0 }, ], activeIndex: 0, }; const manager = new AccountManager(undefined, stored); // For Claude, this method should always return false // (Claude has no gemini-cli fallback, only antigravity) const hasOther = manager.hasOtherAccountWithAntigravityAvailable( 0, "claude", null ); expect(hasOther).toBe(false); }); }); describe("Pre-check fallback logic", () => { it("should switch to account with antigravity rather than fall back to gemini-cli", () => { const stored: AccountStorageV4 = { version: 4, accounts: [ { refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 }, { refreshToken: "r2", projectId: "p2", addedAt: 1, lastUsed: 0 }, ], activeIndex: 0, activeIndexByFamily: { claude: 0, gemini: 0 }, }; const manager = new AccountManager(undefined, stored); const accounts = manager.getAccounts(); // Account 0's antigravity is rate-limited but gemini-cli is available manager.markRateLimited(accounts[0]!, 60000, "gemini", "antigravity"); // Account 1's antigravity is available // (not rate-limited for antigravity) // When requesting with antigravity headerStyle: // Should switch to account 1 (which has antigravity), NOT fall back to gemini-cli const nextAccount = manager.getCurrentOrNextForFamily( "gemini", null, "sticky", "antigravity" ); expect(nextAccount?.index).toBe(1); expect(manager.isRateLimitedForHeaderStyle(nextAccount!, "gemini", "antigravity")).toBe(false); }); it("should only fall back to gemini-cli when ALL accounts exhausted antigravity", () => { const stored: AccountStorageV4 = { version: 4, accounts: [ { refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 }, { refreshToken: "r2", projectId: "p2", addedAt: 1, lastUsed: 0 }, ], activeIndex: 0, activeIndexByFamily: { claude: 0, gemini: 0 }, }; const manager = new AccountManager(undefined, stored); const accounts = manager.getAccounts(); // Both accounts' antigravity are rate-limited manager.markRateLimited(accounts[0]!, 60000, "gemini", "antigravity"); manager.markRateLimited(accounts[1]!, 60000, "gemini", "antigravity"); // Verify no account has antigravity available expect(manager.hasOtherAccountWithAntigravityAvailable(0, "gemini", null)).toBe(false); expect(manager.hasOtherAccountWithAntigravityAvailable(1, "gemini", null)).toBe(false); // Account 0's gemini-cli should still be available for fallback expect(manager.isRateLimitedForHeaderStyle(accounts[0]!, "gemini", "gemini-cli")).toBe(false); expect(manager.getAvailableHeaderStyle(accounts[0]!, "gemini")).toBe("gemini-cli"); }); }); }); ================================================ FILE: src/plugin/auth.test.ts ================================================ import { beforeEach, describe, expect, it, vi } from "vitest"; import { isOAuthAuth, parseRefreshParts, formatRefreshParts, accessTokenExpired } from "./auth"; import type { OAuthAuthDetails, ApiKeyAuthDetails } from "./types"; describe("isOAuthAuth", () => { it("returns true for oauth auth type", () => { const auth: OAuthAuthDetails = { type: "oauth", refresh: "token|project", access: "access-token", expires: Date.now() + 3600000, }; expect(isOAuthAuth(auth)).toBe(true); }); it("returns false for api_key auth type", () => { const auth: ApiKeyAuthDetails = { type: "api_key", key: "some-api-key", }; expect(isOAuthAuth(auth)).toBe(false); }); }); describe("parseRefreshParts", () => { it("parses refresh token with all parts", () => { const result = parseRefreshParts("refreshToken|projectId|managedProjectId"); expect(result).toEqual({ refreshToken: "refreshToken", projectId: "projectId", managedProjectId: "managedProjectId", }); }); it("parses refresh token with only refresh and project", () => { const result = parseRefreshParts("refreshToken|projectId"); expect(result).toEqual({ refreshToken: "refreshToken", projectId: "projectId", managedProjectId: undefined, }); }); it("parses refresh token with only refresh token", () => { const result = parseRefreshParts("refreshToken"); expect(result).toEqual({ refreshToken: "refreshToken", projectId: undefined, managedProjectId: undefined, }); }); it("handles empty string", () => { const result = parseRefreshParts(""); expect(result).toEqual({ refreshToken: "", projectId: undefined, managedProjectId: undefined, }); }); it("handles empty parts", () => { const result = parseRefreshParts("refreshToken||managedProjectId"); expect(result).toEqual({ refreshToken: "refreshToken", projectId: undefined, managedProjectId: "managedProjectId", }); }); it("handles undefined/null-like input", () => { // @ts-expect-error - testing edge case const result = parseRefreshParts(undefined); expect(result).toEqual({ refreshToken: "", projectId: undefined, managedProjectId: undefined, }); }); }); describe("formatRefreshParts", () => { it("formats all parts", () => { const result = formatRefreshParts({ refreshToken: "refreshToken", projectId: "projectId", managedProjectId: "managedProjectId", }); expect(result).toBe("refreshToken|projectId|managedProjectId"); }); it("formats without managed project id", () => { const result = formatRefreshParts({ refreshToken: "refreshToken", projectId: "projectId", }); expect(result).toBe("refreshToken|projectId"); }); it("formats without project id but with managed project id", () => { const result = formatRefreshParts({ refreshToken: "refreshToken", managedProjectId: "managedProjectId", }); expect(result).toBe("refreshToken||managedProjectId"); }); it("formats with only refresh token", () => { const result = formatRefreshParts({ refreshToken: "refreshToken", }); expect(result).toBe("refreshToken|"); }); it("round-trips correctly with parseRefreshParts", () => { const original = { refreshToken: "rt123", projectId: "proj456", managedProjectId: "managed789", }; const formatted = formatRefreshParts(original); const parsed = parseRefreshParts(formatted); expect(parsed).toEqual(original); }); }); describe("accessTokenExpired", () => { beforeEach(() => { vi.useRealTimers(); }); it("returns true when access token is missing", () => { const auth: OAuthAuthDetails = { type: "oauth", refresh: "token", access: undefined, expires: Date.now() + 3600000, }; expect(accessTokenExpired(auth)).toBe(true); }); it("returns true when expires is missing", () => { const auth: OAuthAuthDetails = { type: "oauth", refresh: "token", access: "access-token", expires: undefined, }; expect(accessTokenExpired(auth)).toBe(true); }); it("returns true when token is expired", () => { const auth: OAuthAuthDetails = { type: "oauth", refresh: "token", access: "access-token", expires: Date.now() - 1000, // expired 1 second ago }; expect(accessTokenExpired(auth)).toBe(true); }); it("returns true when token expires within buffer period (60 seconds)", () => { const auth: OAuthAuthDetails = { type: "oauth", refresh: "token", access: "access-token", expires: Date.now() + 30000, // expires in 30 seconds (within 60s buffer) }; expect(accessTokenExpired(auth)).toBe(true); }); it("returns false when token is valid and outside buffer period", () => { const auth: OAuthAuthDetails = { type: "oauth", refresh: "token", access: "access-token", expires: Date.now() + 120000, // expires in 2 minutes }; expect(accessTokenExpired(auth)).toBe(false); }); it("returns false when token expires exactly at buffer boundary", () => { vi.useFakeTimers(); vi.setSystemTime(new Date(0)); const auth: OAuthAuthDetails = { type: "oauth", refresh: "token", access: "access-token", expires: 60001, // expires 60001ms from now, just outside 60s buffer }; expect(accessTokenExpired(auth)).toBe(false); }); }); ================================================ FILE: src/plugin/auth.ts ================================================ import type { AuthDetails, OAuthAuthDetails, RefreshParts } from "./types"; const ACCESS_TOKEN_EXPIRY_BUFFER_MS = 60 * 1000; export function isOAuthAuth(auth: AuthDetails): auth is OAuthAuthDetails { return auth.type === "oauth"; } /** * Splits a packed refresh string into its constituent refresh token and project IDs. */ export function parseRefreshParts(refresh: string): RefreshParts { const [refreshToken = "", projectId = "", managedProjectId = ""] = (refresh ?? "").split("|"); return { refreshToken, projectId: projectId || undefined, managedProjectId: managedProjectId || undefined, }; } /** * Serializes refresh token parts into the stored string format. */ export function formatRefreshParts(parts: RefreshParts): string { const projectSegment = parts.projectId ?? ""; const base = `${parts.refreshToken}|${projectSegment}`; return parts.managedProjectId ? `${base}|${parts.managedProjectId}` : base; } /** * Determines whether an access token is expired or missing, with buffer for clock skew. */ export function accessTokenExpired(auth: OAuthAuthDetails): boolean { if (!auth.access || typeof auth.expires !== "number") { return true; } return auth.expires <= Date.now() + ACCESS_TOKEN_EXPIRY_BUFFER_MS; } /** * Calculates absolute expiry timestamp based on a duration. * @param requestTimeMs The local time when the request was initiated * @param expiresInSeconds The duration returned by the server */ export function calculateTokenExpiry(requestTimeMs: number, expiresInSeconds: unknown): number { const seconds = typeof expiresInSeconds === "number" ? expiresInSeconds : 3600; // Safety check for bad data - if it's not a positive number, treat as immediately expired if (isNaN(seconds) || seconds <= 0) { return requestTimeMs; } return requestTimeMs + seconds * 1000; } ================================================ FILE: src/plugin/cache/index.ts ================================================ /** * Cache module for opencode-antigravity-auth plugin. */ export { SignatureCache, createSignatureCache, } from "./signature-cache"; ================================================ FILE: src/plugin/cache/signature-cache.ts ================================================ /** * Signature cache for persisting thinking block signatures to disk. * * Features (based on LLM-API-Key-Proxy's ProviderCache): * - Dual-TTL system: short memory TTL, longer disk TTL * - Background disk persistence with batched writes * - Atomic writes with temp file + move pattern * - Automatic cleanup of expired entries * * Cache key format: `${sessionId}:${modelId}` */ import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync, unlinkSync } from "node:fs"; import { join, dirname } from "node:path"; import { homedir } from "node:os"; import { tmpdir } from "node:os"; import type { SignatureCacheConfig } from "../config"; import { ensureGitignoreSync } from "../storage"; // ============================================================================= // Types // ============================================================================= interface CacheEntry { value: string; timestamp: number; /** Full thinking text content (optional, for recovery) */ thinkingText?: string; /** Preview of the thinking text for debugging */ textPreview?: string; /** Tool call IDs associated with this thinking block */ toolIds?: string[]; } interface CacheData { version: "1.0"; memory_ttl_seconds: number; disk_ttl_seconds: number; entries: Record; statistics: { memory_hits: number; disk_hits: number; misses: number; writes: number; last_write: number; }; } interface CacheStats { memoryHits: number; diskHits: number; misses: number; writes: number; memoryEntries: number; dirty: boolean; diskEnabled: boolean; } /** * Full thinking content with signature (for recovery) */ export interface ThinkingCacheData { text: string; signature: string; toolIds?: string[]; } // ============================================================================= // Path Utilities // ============================================================================= function getConfigDir(): string { const platform = process.platform; if (platform === "win32") { return join(process.env.APPDATA || join(homedir(), "AppData", "Roaming"), "opencode"); } const xdgConfig = process.env.XDG_CONFIG_HOME || join(homedir(), ".config"); return join(xdgConfig, "opencode"); } function getCacheFilePath(): string { return join(getConfigDir(), "antigravity-signature-cache.json"); } // ============================================================================= // Signature Cache Class // ============================================================================= export class SignatureCache { // In-memory cache: key -> entry with signature and optional thinking text private cache: Map = new Map(); // Configuration private memoryTtlMs: number; private diskTtlMs: number; private writeIntervalMs: number; private cacheFilePath: string; private enabled: boolean; // State private dirty: boolean = false; private writeTimer: ReturnType | null = null; private cleanupTimer: ReturnType | null = null; // Statistics private stats = { memoryHits: 0, diskHits: 0, misses: 0, writes: 0, }; constructor(config: SignatureCacheConfig) { this.enabled = config.enabled; this.memoryTtlMs = config.memory_ttl_seconds * 1000; this.diskTtlMs = config.disk_ttl_seconds * 1000; this.writeIntervalMs = config.write_interval_seconds * 1000; this.cacheFilePath = getCacheFilePath(); if (this.enabled) { this.loadFromDisk(); this.startBackgroundTasks(); } } // =========================================================================== // Public API // =========================================================================== /** * Generate a cache key from sessionId and modelId. */ static makeKey(sessionId: string, modelId: string): string { return `${sessionId}:${modelId}`; } /** * Store a signature in the cache. */ store(key: string, signature: string): void { if (!this.enabled) return; this.cache.set(key, { value: signature, timestamp: Date.now(), }); this.dirty = true; } /** * Retrieve a signature from the cache. * Returns null if not found or expired. */ retrieve(key: string): string | null { if (!this.enabled) return null; const entry = this.cache.get(key); if (entry) { const age = Date.now() - entry.timestamp; if (age <= this.memoryTtlMs) { this.stats.memoryHits++; return entry.value; } // Expired from memory, remove it this.cache.delete(key); } this.stats.misses++; return null; } /** * Check if a key exists in the cache (without updating stats). */ has(key: string): boolean { if (!this.enabled) return false; const entry = this.cache.get(key); if (!entry) return false; const age = Date.now() - entry.timestamp; return age <= this.memoryTtlMs; } // =========================================================================== // Full Thinking Cache (ported from LLM-API-Key-Proxy) // =========================================================================== /** * Store full thinking content with signature. * This enables recovery even after thinking text is stripped by compaction. * * Port of LLM-API-Key-Proxy's _cache_thinking() */ storeThinking( key: string, thinkingText: string, signature: string, toolIds?: string[], ): void { if (!this.enabled || !thinkingText || !signature) return; this.cache.set(key, { value: signature, timestamp: Date.now(), thinkingText, textPreview: thinkingText.slice(0, 100), toolIds, }); this.dirty = true; } /** * Retrieve full thinking content by key. * Returns null if not found or expired. */ retrieveThinking(key: string): ThinkingCacheData | null { if (!this.enabled) return null; const entry = this.cache.get(key); if (!entry || !entry.thinkingText) return null; const age = Date.now() - entry.timestamp; if (age > this.memoryTtlMs) { this.cache.delete(key); return null; } this.stats.memoryHits++; return { text: entry.thinkingText, signature: entry.value, toolIds: entry.toolIds, }; } /** * Check if full thinking content exists for a key. */ hasThinking(key: string): boolean { if (!this.enabled) return false; const entry = this.cache.get(key); if (!entry || !entry.thinkingText) return false; const age = Date.now() - entry.timestamp; return age <= this.memoryTtlMs; } /** * Get cache statistics. */ getStats(): CacheStats { return { ...this.stats, memoryEntries: this.cache.size, dirty: this.dirty, diskEnabled: this.enabled, }; } /** * Manually trigger a disk save. */ async flush(): Promise { if (!this.enabled) return true; return this.saveToDisk(); } /** * Graceful shutdown: stop timers and flush to disk. */ shutdown(): void { if (this.writeTimer) { clearInterval(this.writeTimer); this.writeTimer = null; } if (this.cleanupTimer) { clearInterval(this.cleanupTimer); this.cleanupTimer = null; } if (this.dirty && this.enabled) { this.saveToDisk(); } } // =========================================================================== // Disk Operations // =========================================================================== /** * Load cache from disk file with TTL validation. */ private loadFromDisk(): void { try { if (!existsSync(this.cacheFilePath)) { return; } const content = readFileSync(this.cacheFilePath, "utf-8"); const data = JSON.parse(content) as CacheData; if (data.version !== "1.0") { // Version mismatch - silently start fresh return; } const now = Date.now(); let loaded = 0; let expired = 0; for (const [key, entry] of Object.entries(data.entries)) { const age = now - entry.timestamp; if (age <= this.diskTtlMs) { this.cache.set(key, { value: entry.value, timestamp: entry.timestamp, }); loaded++; } else { expired++; } } // Silently load - no console output } catch { // Silently start fresh on any error (corruption, file not found, etc.) } } /** * Save cache to disk with atomic write pattern. * Merges with existing disk entries that haven't expired. */ private saveToDisk(): boolean { try { // Ensure directory exists const dir = dirname(this.cacheFilePath); if (!existsSync(dir)) { mkdirSync(dir, { recursive: true }); } ensureGitignoreSync(dir); const now = Date.now(); // Step 1: Load existing disk entries (if any) let existingEntries: Record = {}; if (existsSync(this.cacheFilePath)) { try { const content = readFileSync(this.cacheFilePath, "utf-8"); const data = JSON.parse(content) as CacheData; existingEntries = data.entries || {}; } catch { // Start fresh if corrupted } } // Step 2: Filter existing disk entries by disk_ttl const validDiskEntries: Record = {}; for (const [key, entry] of Object.entries(existingEntries)) { const age = now - entry.timestamp; if (age <= this.diskTtlMs) { validDiskEntries[key] = entry; } } // Step 3: Merge - memory entries take precedence const mergedEntries: Record = { ...validDiskEntries }; for (const [key, entry] of this.cache.entries()) { mergedEntries[key] = { value: entry.value, timestamp: entry.timestamp, }; } // Step 4: Build cache data const cacheData: CacheData = { version: "1.0", memory_ttl_seconds: this.memoryTtlMs / 1000, disk_ttl_seconds: this.diskTtlMs / 1000, entries: mergedEntries, statistics: { memory_hits: this.stats.memoryHits, disk_hits: this.stats.diskHits, misses: this.stats.misses, writes: this.stats.writes + 1, last_write: now, }, }; // Step 5: Atomic write (temp file + rename) const tmpPath = join(tmpdir(), `antigravity-cache-${Date.now()}-${Math.random().toString(36).slice(2)}.tmp`); writeFileSync(tmpPath, JSON.stringify(cacheData, null, 2), "utf-8"); try { renameSync(tmpPath, this.cacheFilePath); } catch { // On Windows, rename across volumes may fail // Fall back to copy + delete writeFileSync(this.cacheFilePath, readFileSync(tmpPath)); try { unlinkSync(tmpPath); } catch { // Ignore cleanup errors } } this.stats.writes++; this.dirty = false; return true; } catch { // Silently fail - disk cache is optional return false; } } // =========================================================================== // Background Tasks // =========================================================================== /** * Start background write and cleanup timers. */ private startBackgroundTasks(): void { // Periodic disk writes this.writeTimer = setInterval(() => { if (this.dirty) { this.saveToDisk(); } }, this.writeIntervalMs); // Periodic memory cleanup (every 30 minutes) this.cleanupTimer = setInterval(() => { this.cleanupExpired(); }, 30 * 60 * 1000); } /** * Remove expired entries from memory. */ private cleanupExpired(): void { const now = Date.now(); let cleaned = 0; for (const [key, entry] of this.cache.entries()) { const age = now - entry.timestamp; if (age > this.memoryTtlMs) { this.cache.delete(key); cleaned++; } } // Silently clean - no console output } } // ============================================================================= // Factory Function // ============================================================================= /** * Create a signature cache with the given configuration. * Returns null if caching is disabled. */ export function createSignatureCache(config: SignatureCacheConfig | undefined): SignatureCache | null { if (!config || !config.enabled) { return null; } return new SignatureCache(config); } ================================================ FILE: src/plugin/cache.test.ts ================================================ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { resolveCachedAuth, storeCachedAuth, clearCachedAuth, cacheSignature, getCachedSignature, clearSignatureCache, } from "./cache"; import type { OAuthAuthDetails } from "./types"; function createAuth(overrides: Partial = {}): OAuthAuthDetails { return { type: "oauth", refresh: "refresh-token|project-id", access: "access-token", expires: Date.now() + 3600000, ...overrides, }; } describe("Auth Cache", () => { beforeEach(() => { vi.useRealTimers(); clearCachedAuth(); }); afterEach(() => { clearCachedAuth(); }); describe("resolveCachedAuth", () => { it("returns input auth when no cache exists and caches it", () => { const auth = createAuth(); const result = resolveCachedAuth(auth); expect(result).toEqual(auth); }); it("returns input auth when refresh key is empty", () => { const auth = createAuth({ refresh: "" }); const result = resolveCachedAuth(auth); expect(result).toEqual(auth); }); it("returns input auth when it has valid (unexpired) access token", () => { const oldAuth = createAuth({ access: "old-access", expires: Date.now() + 3600000 }); resolveCachedAuth(oldAuth); // cache it const newAuth = createAuth({ access: "new-access", expires: Date.now() + 7200000 }); const result = resolveCachedAuth(newAuth); expect(result.access).toBe("new-access"); }); it("returns cached auth when input auth is expired but cached is valid", () => { vi.useFakeTimers(); vi.setSystemTime(new Date(0)); const validAuth = createAuth({ access: "valid-access", expires: 3600000, // expires at t=3600000 }); resolveCachedAuth(validAuth); // cache it // Now create an expired auth with the same refresh token const expiredAuth = createAuth({ access: "expired-access", expires: 30000, // expires within buffer (60s) }); const result = resolveCachedAuth(expiredAuth); expect(result.access).toBe("valid-access"); }); it("returns input auth when both are expired (updates cache)", () => { vi.useFakeTimers(); vi.setSystemTime(new Date(0)); const expiredCached = createAuth({ access: "cached-expired", expires: 30000, // expired within buffer }); resolveCachedAuth(expiredCached); const expiredNew = createAuth({ access: "new-expired", expires: 20000, // also expired within buffer }); const result = resolveCachedAuth(expiredNew); expect(result.access).toBe("new-expired"); }); }); describe("storeCachedAuth", () => { it("stores auth in cache", () => { const auth = createAuth({ access: "stored-access" }); storeCachedAuth(auth); const expiredAuth = createAuth({ access: "expired", expires: Date.now() - 1000 }); const result = resolveCachedAuth(expiredAuth); expect(result.access).toBe("stored-access"); }); it("does nothing when refresh key is empty", () => { const auth = createAuth({ refresh: "", access: "no-key-access" }); storeCachedAuth(auth); // Should not be retrievable since key was empty const testAuth = createAuth({ refresh: "", access: "test" }); const result = resolveCachedAuth(testAuth); expect(result.access).toBe("test"); // returns the input, not cached }); it("does nothing when refresh key is whitespace only", () => { const auth = createAuth({ refresh: " ", access: "whitespace-access" }); storeCachedAuth(auth); const testAuth = createAuth({ refresh: " ", access: "test" }); const result = resolveCachedAuth(testAuth); expect(result.access).toBe("test"); }); }); describe("clearCachedAuth", () => { it("clears all cache when no argument provided", () => { storeCachedAuth(createAuth({ refresh: "token1|p", access: "access1" })); storeCachedAuth(createAuth({ refresh: "token2|p", access: "access2" })); clearCachedAuth(); const auth1 = createAuth({ refresh: "token1|p", access: "new1" }); const auth2 = createAuth({ refresh: "token2|p", access: "new2" }); expect(resolveCachedAuth(auth1).access).toBe("new1"); expect(resolveCachedAuth(auth2).access).toBe("new2"); }); it("clears specific refresh token from cache", () => { storeCachedAuth(createAuth({ refresh: "token1|p", access: "access1" })); storeCachedAuth(createAuth({ refresh: "token2|p", access: "access2" })); clearCachedAuth("token1|p"); // token1 should be cleared const expiredAuth1 = createAuth({ refresh: "token1|p", access: "new1", expires: Date.now() - 1000 }); expect(resolveCachedAuth(expiredAuth1).access).toBe("new1"); // token2 should still be cached const expiredAuth2 = createAuth({ refresh: "token2|p", access: "new2", expires: Date.now() - 1000 }); expect(resolveCachedAuth(expiredAuth2).access).toBe("access2"); }); }); }); describe("Signature Cache", () => { beforeEach(() => { vi.useRealTimers(); clearSignatureCache(); }); afterEach(() => { clearSignatureCache(); }); describe("cacheSignature", () => { it("caches a signature for session and text", () => { cacheSignature("session1", "thinking text", "sig123"); const result = getCachedSignature("session1", "thinking text"); expect(result).toBe("sig123"); }); it("does nothing when sessionId is empty", () => { cacheSignature("", "text", "sig"); expect(getCachedSignature("", "text")).toBeUndefined(); }); it("does nothing when text is empty", () => { cacheSignature("session", "", "sig"); expect(getCachedSignature("session", "")).toBeUndefined(); }); it("does nothing when signature is empty", () => { cacheSignature("session", "text", ""); expect(getCachedSignature("session", "text")).toBeUndefined(); }); it("stores multiple signatures per session", () => { cacheSignature("session1", "text1", "sig1"); cacheSignature("session1", "text2", "sig2"); expect(getCachedSignature("session1", "text1")).toBe("sig1"); expect(getCachedSignature("session1", "text2")).toBe("sig2"); }); it("stores signatures for different sessions independently", () => { cacheSignature("session1", "text", "sig1"); cacheSignature("session2", "text", "sig2"); expect(getCachedSignature("session1", "text")).toBe("sig1"); expect(getCachedSignature("session2", "text")).toBe("sig2"); }); }); describe("getCachedSignature", () => { it("returns undefined when session not found", () => { expect(getCachedSignature("unknown", "text")).toBeUndefined(); }); it("returns undefined when text not found in session", () => { cacheSignature("session", "known-text", "sig"); expect(getCachedSignature("session", "unknown-text")).toBeUndefined(); }); it("returns undefined when sessionId is empty", () => { expect(getCachedSignature("", "text")).toBeUndefined(); }); it("returns undefined when text is empty", () => { expect(getCachedSignature("session", "")).toBeUndefined(); }); it("returns undefined when signature is expired", () => { vi.useFakeTimers(); vi.setSystemTime(new Date(0)); cacheSignature("session", "text", "sig"); // Advance time past TTL (1 hour = 3600000ms) vi.setSystemTime(new Date(3600001)); expect(getCachedSignature("session", "text")).toBeUndefined(); }); it("returns signature when not expired", () => { vi.useFakeTimers(); vi.setSystemTime(new Date(0)); cacheSignature("session", "text", "sig"); // Advance time but stay within TTL vi.setSystemTime(new Date(3599999)); expect(getCachedSignature("session", "text")).toBe("sig"); }); }); describe("clearSignatureCache", () => { it("clears all signature cache when no argument provided", () => { cacheSignature("session1", "text", "sig1"); cacheSignature("session2", "text", "sig2"); clearSignatureCache(); expect(getCachedSignature("session1", "text")).toBeUndefined(); expect(getCachedSignature("session2", "text")).toBeUndefined(); }); it("clears specific session from cache", () => { cacheSignature("session1", "text", "sig1"); cacheSignature("session2", "text", "sig2"); clearSignatureCache("session1"); expect(getCachedSignature("session1", "text")).toBeUndefined(); expect(getCachedSignature("session2", "text")).toBe("sig2"); }); }); describe("cache eviction", () => { it("evicts entries when at capacity", () => { vi.useFakeTimers(); vi.setSystemTime(new Date(0)); // Fill cache with 100 entries (MAX_ENTRIES_PER_SESSION) for (let i = 0; i < 100; i++) { vi.setSystemTime(new Date(i * 1000)); // stagger timestamps cacheSignature("session", `text-${i}`, `sig-${i}`); } // Reset time to check entries vi.setSystemTime(new Date(100 * 1000)); // Adding one more should trigger eviction cacheSignature("session", "new-text", "new-sig"); // New entry should exist expect(getCachedSignature("session", "new-text")).toBe("new-sig"); // Some old entries should have been evicted (oldest 25%) // Entry at index 0 (timestamp 0) should be evicted expect(getCachedSignature("session", "text-0")).toBeUndefined(); }); }); }); ================================================ FILE: src/plugin/cache.ts ================================================ import { accessTokenExpired } from "./auth"; import type { OAuthAuthDetails } from "./types"; import { createHash } from "node:crypto"; const authCache = new Map(); /** * Produces a stable cache key from a refresh token string. */ function normalizeRefreshKey(refresh?: string): string | undefined { const key = refresh?.trim(); return key ? key : undefined; } /** * Returns a cached auth snapshot when available, favoring unexpired tokens. */ export function resolveCachedAuth(auth: OAuthAuthDetails): OAuthAuthDetails { const key = normalizeRefreshKey(auth.refresh); if (!key) { return auth; } const cached = authCache.get(key); if (!cached) { authCache.set(key, auth); return auth; } if (!accessTokenExpired(auth)) { authCache.set(key, auth); return auth; } if (!accessTokenExpired(cached)) { return cached; } authCache.set(key, auth); return auth; } /** * Stores the latest auth snapshot keyed by refresh token. */ export function storeCachedAuth(auth: OAuthAuthDetails): void { const key = normalizeRefreshKey(auth.refresh); if (!key) { return; } authCache.set(key, auth); } /** * Clears cached auth globally or for a specific refresh token. */ export function clearCachedAuth(refresh?: string): void { if (!refresh) { authCache.clear(); return; } const key = normalizeRefreshKey(refresh); if (key) { authCache.delete(key); } } // ============================================================================ // Thinking Signature Cache (for Claude multi-turn conversations) // ============================================================================ import { SignatureCache, createSignatureCache } from "./cache/signature-cache"; import type { SignatureCacheConfig } from "./config"; interface SignatureEntry { signature: string; timestamp: number; } // Map: sessionId -> Map const signatureCache = new Map>(); // Cache entries expire after 1 hour const SIGNATURE_CACHE_TTL_MS = 60 * 60 * 1000; // Maximum entries per session to prevent memory bloat const MAX_ENTRIES_PER_SESSION = 100; // 16 hex chars = 64-bit key space; keeps memory bounded while making collisions extremely unlikely. const SIGNATURE_TEXT_HASH_HEX_LEN = 16; // Disk cache instance (initialized via initDiskSignatureCache) let diskCache: SignatureCache | null = null; /** * Initialize the disk-based signature cache. * Call this from plugin initialization when keep_thinking is enabled. */ export function initDiskSignatureCache(config: SignatureCacheConfig | undefined): SignatureCache | null { diskCache = createSignatureCache(config); return diskCache; } /** * Get the disk cache instance (for testing/debugging). */ export function getDiskSignatureCache(): SignatureCache | null { return diskCache; } /** * Hashes text content into a stable, Unicode-safe key. * * Uses SHA-256 over UTF-8 bytes and truncates to keep memory usage bounded. */ function hashText(text: string): string { return createHash("sha256").update(text, "utf8").digest("hex").slice(0, SIGNATURE_TEXT_HASH_HEX_LEN); } /** * Create a disk cache key from sessionId and textHash. */ function makeDiskKey(sessionId: string, textHash: string): string { return `${sessionId}:${textHash}`; } /** * Caches a thinking signature for a given session and text. * Used for Claude models that require signed thinking blocks in multi-turn conversations. * Also writes to disk cache if enabled. */ export function cacheSignature(sessionId: string, text: string, signature: string): void { if (!sessionId || !text || !signature) return; const textHash = hashText(text); // Write to memory cache let sessionMemCache = signatureCache.get(sessionId); if (!sessionMemCache) { sessionMemCache = new Map(); signatureCache.set(sessionId, sessionMemCache); } // Evict old entries if we're at capacity if (sessionMemCache.size >= MAX_ENTRIES_PER_SESSION) { const now = Date.now(); for (const [key, entry] of sessionMemCache.entries()) { if (now - entry.timestamp > SIGNATURE_CACHE_TTL_MS) { sessionMemCache.delete(key); } } // If still at capacity, remove oldest entries if (sessionMemCache.size >= MAX_ENTRIES_PER_SESSION) { const entries = Array.from(sessionMemCache.entries()) .sort((a, b) => a[1].timestamp - b[1].timestamp); const toRemove = entries.slice(0, Math.floor(MAX_ENTRIES_PER_SESSION / 4)); for (const [key] of toRemove) { sessionMemCache.delete(key); } } } sessionMemCache.set(textHash, { signature, timestamp: Date.now() }); // Write to disk cache if enabled if (diskCache) { const diskKey = makeDiskKey(sessionId, textHash); diskCache.store(diskKey, signature); } } /** * Retrieves a cached signature for a given session and text. * Checks memory first, then falls back to disk cache. * Returns undefined if not found or expired. */ export function getCachedSignature(sessionId: string, text: string): string | undefined { if (!sessionId || !text) return undefined; const textHash = hashText(text); // Check memory cache first const sessionMemCache = signatureCache.get(sessionId); if (sessionMemCache) { const entry = sessionMemCache.get(textHash); if (entry) { // Check if expired if (Date.now() - entry.timestamp > SIGNATURE_CACHE_TTL_MS) { sessionMemCache.delete(textHash); } else { return entry.signature; } } } // Fall back to disk cache if (diskCache) { const diskKey = makeDiskKey(sessionId, textHash); const diskValue = diskCache.retrieve(diskKey); if (diskValue) { // Promote to memory cache for faster subsequent access let memCache = signatureCache.get(sessionId); if (!memCache) { memCache = new Map(); signatureCache.set(sessionId, memCache); } memCache.set(textHash, { signature: diskValue, timestamp: Date.now() }); return diskValue; } } return undefined; } /** * Clears signature cache for a specific session or all sessions. * Also clears from disk cache if enabled. */ export function clearSignatureCache(sessionId?: string): void { if (sessionId) { signatureCache.delete(sessionId); // Note: We don't clear individual sessions from disk cache to avoid // expensive iteration. Disk cache entries will expire naturally. } else { signatureCache.clear(); // For full clear, we could clear disk cache, but leaving it for now // since entries have TTL and will expire naturally. } } // ============================================================================ // Disk-Persistent Signature Cache (re-export from cache/ folder) // ============================================================================ // Re-export SignatureCache class and factory for direct use export { SignatureCache, createSignatureCache } from "./cache/signature-cache"; export type { SignatureCacheConfig } from "./config"; ================================================ FILE: src/plugin/cli.ts ================================================ import { createInterface } from "node:readline/promises"; import { stdin as input, stdout as output } from "node:process"; import { showAuthMenu, showAccountDetails, isTTY, type AccountInfo, type AccountStatus, } from "./ui/auth-menu"; import { updateOpencodeConfig } from "./config/updater"; export async function promptProjectId(): Promise { const rl = createInterface({ input, output }); try { const answer = await rl.question("Project ID (leave blank to use your default project): "); return answer.trim(); } finally { rl.close(); } } export async function promptAddAnotherAccount(currentCount: number): Promise { const rl = createInterface({ input, output }); try { const answer = await rl.question(`Add another account? (${currentCount} added) (y/n): `); const normalized = answer.trim().toLowerCase(); return normalized === "y" || normalized === "yes"; } finally { rl.close(); } } export type LoginMode = "add" | "fresh" | "manage" | "check" | "verify" | "verify-all" | "cancel"; export interface ExistingAccountInfo { email?: string; index: number; addedAt?: number; lastUsed?: number; status?: AccountStatus; isCurrentAccount?: boolean; enabled?: boolean; } export interface LoginMenuResult { mode: LoginMode; deleteAccountIndex?: number; refreshAccountIndex?: number; toggleAccountIndex?: number; verifyAccountIndex?: number; verifyAll?: boolean; deleteAll?: boolean; } async function promptLoginModeFallback(existingAccounts: ExistingAccountInfo[]): Promise { const rl = createInterface({ input, output }); try { console.log(`\n${existingAccounts.length} account(s) saved:`); for (const acc of existingAccounts) { const label = acc.email || `Account ${acc.index + 1}`; console.log(` ${acc.index + 1}. ${label}`); } console.log(""); while (true) { const answer = await rl.question("(a)dd new, (f)resh start, (c)heck quotas, (v)erify account, (va) verify all? [a/f/c/v/va]: "); const normalized = answer.trim().toLowerCase(); if (normalized === "a" || normalized === "add") { return { mode: "add" }; } if (normalized === "f" || normalized === "fresh") { return { mode: "fresh" }; } if (normalized === "c" || normalized === "check") { return { mode: "check" }; } if (normalized === "v" || normalized === "verify") { return { mode: "verify" }; } if (normalized === "va" || normalized === "verify-all" || normalized === "all") { return { mode: "verify-all", verifyAll: true }; } console.log("Please enter 'a', 'f', 'c', 'v', or 'va'."); } } finally { rl.close(); } } export async function promptLoginMode(existingAccounts: ExistingAccountInfo[]): Promise { if (!isTTY()) { return promptLoginModeFallback(existingAccounts); } const accounts: AccountInfo[] = existingAccounts.map(acc => ({ email: acc.email, index: acc.index, addedAt: acc.addedAt, lastUsed: acc.lastUsed, status: acc.status, isCurrentAccount: acc.isCurrentAccount, enabled: acc.enabled, })); console.log(""); while (true) { const action = await showAuthMenu(accounts); switch (action.type) { case "add": return { mode: "add" }; case "check": return { mode: "check" }; case "verify": return { mode: "verify" }; case "verify-all": return { mode: "verify-all", verifyAll: true }; case "select-account": { const accountAction = await showAccountDetails(action.account); if (accountAction === "delete") { return { mode: "add", deleteAccountIndex: action.account.index }; } if (accountAction === "refresh") { return { mode: "add", refreshAccountIndex: action.account.index }; } if (accountAction === "toggle") { return { mode: "manage", toggleAccountIndex: action.account.index }; } if (accountAction === "verify") { return { mode: "verify", verifyAccountIndex: action.account.index }; } continue; } case "delete-all": return { mode: "fresh", deleteAll: true }; case "configure-models": { const result = await updateOpencodeConfig(); if (result.success) { console.log(`\n✓ Models configured in ${result.configPath}\n`); } else { console.log(`\n✗ Failed to configure models: ${result.error}\n`); } continue; } case "cancel": return { mode: "cancel" }; } } } export { isTTY } from "./ui/auth-menu"; export type { AccountStatus } from "./ui/auth-menu"; ================================================ FILE: src/plugin/config/index.ts ================================================ /** * Configuration module for opencode-antigravity-auth plugin. * * @example * ```typescript * import { loadConfig, type AntigravityConfig } from "./config"; * * const config = loadConfig(directory); * if (config.session_recovery) { * // Enable session recovery * } * ``` */ export { AntigravityConfigSchema, SignatureCacheConfigSchema, DEFAULT_CONFIG, type AntigravityConfig, type SignatureCacheConfig, } from "./schema"; export { loadConfig, getUserConfigPath, getProjectConfigPath, getDefaultLogsDir, configExists, initRuntimeConfig, getKeepThinking, } from "./loader"; ================================================ FILE: src/plugin/config/loader.ts ================================================ /** * Configuration loader for opencode-antigravity-auth plugin. * * Loads config from files. * Priority (lowest to highest): * 1. Schema defaults * 2. User config file * 3. Project config file */ import { existsSync, readFileSync } from "node:fs"; import { join } from "node:path"; import { homedir } from "node:os"; import { AntigravityConfigSchema, DEFAULT_CONFIG, type AntigravityConfig } from "./schema"; import { createLogger } from "../logger"; const log = createLogger("config"); // ============================================================================= // Path Utilities // ============================================================================= /** * Get the config directory path, with the following precedence: * 1. OPENCODE_CONFIG_DIR env var (if set) * 2. ~/.config/opencode (all platforms, including Windows) */ function getConfigDir(): string { // 1. Check for explicit override via env var if (process.env.OPENCODE_CONFIG_DIR) { return process.env.OPENCODE_CONFIG_DIR; } // 2. Use ~/.config/opencode on all platforms (including Windows) const xdgConfig = process.env.XDG_CONFIG_HOME || join(homedir(), ".config"); return join(xdgConfig, "opencode"); } /** * Get the user-level config file path. */ export function getUserConfigPath(): string { return join(getConfigDir(), "antigravity.json"); } /** * Get the project-level config file path. */ export function getProjectConfigPath(directory: string): string { return join(directory, ".opencode", "antigravity.json"); } // ============================================================================= // Config Loading // ============================================================================= /** * Load and parse a config file, returning null if not found or invalid. */ function loadConfigFile(path: string): Partial | null { try { if (!existsSync(path)) { return null; } const content = readFileSync(path, "utf-8"); const rawConfig = JSON.parse(content); // Validate with Zod (partial - we'll merge with defaults later) const result = AntigravityConfigSchema.partial().safeParse(rawConfig); if (!result.success) { log.warn("Config validation error", { path, issues: result.error.issues.map(i => `${i.path.join(".")}: ${i.message}`).join(", "), }); return null; } return result.data; } catch (error) { if (error instanceof SyntaxError) { log.warn("Invalid JSON in config file", { path, error: error.message }); } else { log.warn("Failed to load config file", { path, error: String(error) }); } return null; } } /** * Deep merge two config objects, with override taking precedence. */ function mergeConfigs( base: AntigravityConfig, override: Partial ): AntigravityConfig { return { ...base, ...override, // Deep merge signature_cache if both exist signature_cache: override.signature_cache ? { ...base.signature_cache, ...override.signature_cache, } : base.signature_cache, }; } // ============================================================================= // Main Loader // ============================================================================= /** * Load the complete configuration. * * @param directory - The project directory (for project-level config) * @returns Fully resolved configuration */ export function loadConfig(directory: string): AntigravityConfig { // Start with defaults let config: AntigravityConfig = { ...DEFAULT_CONFIG }; // Load user config file (if exists) const userConfigPath = getUserConfigPath(); const userConfig = loadConfigFile(userConfigPath); if (userConfig) { config = mergeConfigs(config, userConfig); } // Load project config file (if exists) - overrides user config const projectConfigPath = getProjectConfigPath(directory); const projectConfig = loadConfigFile(projectConfigPath); if (projectConfig) { config = mergeConfigs(config, projectConfig); } return config; } /** * Check if a config file exists at the given path. */ export function configExists(path: string): boolean { return existsSync(path); } /** * Get the default logs directory. */ export function getDefaultLogsDir(): string { return join(getConfigDir(), "antigravity-logs"); } let runtimeConfig: AntigravityConfig | null = null; export function initRuntimeConfig(config: AntigravityConfig): void { runtimeConfig = config; } export function getKeepThinking(): boolean { return runtimeConfig?.keep_thinking ?? false; } ================================================ FILE: src/plugin/config/models.test.ts ================================================ import { describe, expect, it } from "vitest"; import { OPENCODE_MODEL_DEFINITIONS } from "./models"; const getModel = (name: string) => { const model = OPENCODE_MODEL_DEFINITIONS[name]; if (!model) { throw new Error(`Missing model definition for ${name}`); } return model; }; describe("OPENCODE_MODEL_DEFINITIONS", () => { it("includes the full set of configured models", () => { const modelNames = Object.keys(OPENCODE_MODEL_DEFINITIONS).sort(); expect(modelNames).toEqual([ "antigravity-claude-opus-4-6-thinking", "antigravity-claude-sonnet-4-6", "antigravity-gemini-3-flash", "antigravity-gemini-3-pro", "antigravity-gemini-3.1-pro", "gemini-2.5-flash", "gemini-2.5-pro", "gemini-3-flash-preview", "gemini-3-pro-preview", "gemini-3.1-pro-preview", "gemini-3.1-pro-preview-customtools", ]); }); it("defines Gemini 3 variants for Antigravity models", () => { expect(getModel("antigravity-gemini-3-pro").variants).toEqual({ low: { thinkingLevel: "low" }, high: { thinkingLevel: "high" }, }); expect(getModel("antigravity-gemini-3.1-pro").variants).toEqual({ low: { thinkingLevel: "low" }, high: { thinkingLevel: "high" }, }); expect(getModel("antigravity-gemini-3-flash").variants).toEqual({ minimal: { thinkingLevel: "minimal" }, low: { thinkingLevel: "low" }, medium: { thinkingLevel: "medium" }, high: { thinkingLevel: "high" }, }); }); it("defines thinking budget variants for Claude thinking models", () => { expect(getModel("antigravity-claude-opus-4-6-thinking").variants).toEqual({ low: { thinkingConfig: { thinkingBudget: 8192 } }, max: { thinkingConfig: { thinkingBudget: 32768 } }, }); }); }); ================================================ FILE: src/plugin/config/models.ts ================================================ import type { ProviderModel } from "../types"; export type ModelThinkingLevel = "minimal" | "low" | "medium" | "high"; export interface ModelThinkingConfig { thinkingBudget: number; } export interface ModelVariant { thinkingLevel?: ModelThinkingLevel; thinkingConfig?: ModelThinkingConfig; } export interface ModelLimit { context: number; output: number; } export type ModelModality = "text" | "image" | "pdf"; export interface ModelModalities { input: ModelModality[]; output: ModelModality[]; } export interface OpencodeModelDefinition extends ProviderModel { name: string; limit: ModelLimit; modalities: ModelModalities; variants?: Record; } export type OpencodeModelDefinitions = Record; const DEFAULT_MODALITIES: ModelModalities = { input: ["text", "image", "pdf"], output: ["text"], }; export const OPENCODE_MODEL_DEFINITIONS: OpencodeModelDefinitions = { "antigravity-gemini-3-pro": { name: "Gemini 3 Pro (Antigravity)", limit: { context: 1048576, output: 65535 }, modalities: DEFAULT_MODALITIES, variants: { low: { thinkingLevel: "low" }, high: { thinkingLevel: "high" }, }, }, "antigravity-gemini-3.1-pro": { name: "Gemini 3.1 Pro (Antigravity)", limit: { context: 1048576, output: 65535 }, modalities: DEFAULT_MODALITIES, variants: { low: { thinkingLevel: "low" }, high: { thinkingLevel: "high" }, }, }, "antigravity-gemini-3-flash": { name: "Gemini 3 Flash (Antigravity)", limit: { context: 1048576, output: 65536 }, modalities: DEFAULT_MODALITIES, variants: { minimal: { thinkingLevel: "minimal" }, low: { thinkingLevel: "low" }, medium: { thinkingLevel: "medium" }, high: { thinkingLevel: "high" }, }, }, "antigravity-claude-sonnet-4-6": { name: "Claude Sonnet 4.6 (Antigravity)", limit: { context: 200000, output: 64000 }, modalities: DEFAULT_MODALITIES, }, "antigravity-claude-opus-4-6-thinking": { name: "Claude Opus 4.6 Thinking (Antigravity)", limit: { context: 200000, output: 64000 }, modalities: DEFAULT_MODALITIES, variants: { low: { thinkingConfig: { thinkingBudget: 8192 } }, max: { thinkingConfig: { thinkingBudget: 32768 } }, }, }, "gemini-2.5-flash": { name: "Gemini 2.5 Flash (Gemini CLI)", limit: { context: 1048576, output: 65536 }, modalities: DEFAULT_MODALITIES, }, "gemini-2.5-pro": { name: "Gemini 2.5 Pro (Gemini CLI)", limit: { context: 1048576, output: 65536 }, modalities: DEFAULT_MODALITIES, }, "gemini-3-flash-preview": { name: "Gemini 3 Flash Preview (Gemini CLI)", limit: { context: 1048576, output: 65536 }, modalities: DEFAULT_MODALITIES, }, "gemini-3-pro-preview": { name: "Gemini 3 Pro Preview (Gemini CLI)", limit: { context: 1048576, output: 65535 }, modalities: DEFAULT_MODALITIES, }, "gemini-3.1-pro-preview": { name: "Gemini 3.1 Pro Preview (Gemini CLI)", limit: { context: 1048576, output: 65535 }, modalities: DEFAULT_MODALITIES, }, "gemini-3.1-pro-preview-customtools": { name: "Gemini 3.1 Pro Preview Custom Tools (Gemini CLI)", limit: { context: 1048576, output: 65535 }, modalities: DEFAULT_MODALITIES, }, }; ================================================ FILE: src/plugin/config/schema.test.ts ================================================ import { readFileSync } from "node:fs"; import { describe, expect, it } from "vitest"; import { DEFAULT_CONFIG } from "./schema"; describe("cli_first config", () => { it("includes cli_first default in DEFAULT_CONFIG", () => { expect(DEFAULT_CONFIG).toHaveProperty("cli_first", false); }); it("documents cli_first in the JSON schema", () => { const schemaPath = new URL("../../../assets/antigravity.schema.json", import.meta.url); const schema = JSON.parse(readFileSync(schemaPath, "utf8")) as { properties?: Record; }; const cliFirst = schema.properties?.cli_first; expect(cliFirst).toBeDefined(); expect(cliFirst).toMatchObject({ type: "boolean", default: false, }); expect(typeof cliFirst?.description).toBe("string"); expect(cliFirst?.description?.length ?? 0).toBeGreaterThan(0); }); }); describe("claude_prompt_auto_caching config", () => { it("includes claude_prompt_auto_caching default in DEFAULT_CONFIG", () => { expect(DEFAULT_CONFIG).toHaveProperty("claude_prompt_auto_caching", false); }); it("documents claude_prompt_auto_caching in the JSON schema", () => { const schemaPath = new URL("../../../assets/antigravity.schema.json", import.meta.url); const schema = JSON.parse(readFileSync(schemaPath, "utf8")) as { properties?: Record; }; const claudePromptAutoCaching = schema.properties?.claude_prompt_auto_caching; expect(claudePromptAutoCaching).toBeDefined(); expect(claudePromptAutoCaching).toMatchObject({ type: "boolean", default: false, }); expect(typeof claudePromptAutoCaching?.description).toBe("string"); expect(claudePromptAutoCaching?.description?.length ?? 0).toBeGreaterThan(0); }); }); ================================================ FILE: src/plugin/config/schema.ts ================================================ /** * Configuration schema for opencode-antigravity-auth plugin. * * Config file locations (in priority order, highest wins): * - Project: .opencode/antigravity.json * - User: ~/.config/opencode/antigravity.json (Linux/Mac) * %APPDATA%\opencode\antigravity.json (Windows) * * Environment variables always override config file values. */ import { z } from "zod"; /** * Account selection strategy for distributing requests across accounts. * * - `sticky`: Use same account until rate-limited. Preserves prompt cache. * - `round-robin`: Rotate to next account on every request. Maximum throughput. * - `hybrid` (default): Deterministic selection based on health score + token bucket + LRU freshness. */ export const AccountSelectionStrategySchema = z.enum(['sticky', 'round-robin', 'hybrid']); export type AccountSelectionStrategy = z.infer; /** * Toast notification scope for controlling which sessions show toasts. * * - `root_only` (default): Only show toasts for root sessions (no parentID). * Subagents and background tasks won't show toast notifications. * - `all`: Show toasts for all sessions including subagents and background tasks. */ export const ToastScopeSchema = z.enum(['root_only', 'all']); export type ToastScope = z.infer; /** * Scheduling mode for rate limit behavior. * * - `cache_first`: Wait for same account to recover (preserves prompt cache). Default. * - `balance`: Switch account immediately on rate limit. Maximum availability. * - `performance_first`: Round-robin distribution for maximum throughput. */ export const SchedulingModeSchema = z.enum(['cache_first', 'balance', 'performance_first']); export type SchedulingMode = z.infer; /** * Signature cache configuration for persisting thinking block signatures to disk. */ export const SignatureCacheConfigSchema = z.object({ /** Enable disk caching of signatures (default: true) */ enabled: z.boolean().default(true), /** In-memory TTL in seconds (default: 3600 = 1 hour) */ memory_ttl_seconds: z.number().min(60).max(86400).default(3600), /** Disk TTL in seconds (default: 172800 = 48 hours) */ disk_ttl_seconds: z.number().min(3600).max(604800).default(172800), /** Background write interval in seconds (default: 60) */ write_interval_seconds: z.number().min(10).max(600).default(60), }); /** * Main configuration schema for the Antigravity OAuth plugin. */ export const AntigravityConfigSchema = z.object({ /** JSON Schema reference for IDE support */ $schema: z.string().optional(), // ========================================================================= // General Settings // ========================================================================= /** * Suppress most toast notifications (rate limit, account switching, etc.) * Recovery toasts are always shown regardless of this setting. * Env override: OPENCODE_ANTIGRAVITY_QUIET=1 * @default false */ quiet_mode: z.boolean().default(false), /** * Control which sessions show toast notifications. * * - `root_only` (default): Only root sessions show toasts. * Subagents and background tasks will be silent (less spam). * - `all`: All sessions show toasts including subagents and background tasks. * * Debug logging captures all toasts regardless of this setting. * Env override: OPENCODE_ANTIGRAVITY_TOAST_SCOPE=all * @default "root_only" */ toast_scope: ToastScopeSchema.default('root_only'), /** * Enable debug logging to file. * Env override: OPENCODE_ANTIGRAVITY_DEBUG=1 * @default false */ debug: z.boolean().default(false), /** * Show debug logs in the TUI log panel. * Works independently from `debug` file logging. * Env override: OPENCODE_ANTIGRAVITY_DEBUG_TUI=1 * @default false */ debug_tui: z.boolean().default(false), /** * Custom directory for debug logs. * Env override: OPENCODE_ANTIGRAVITY_LOG_DIR=/path/to/logs * @default OS-specific config dir + "/antigravity-logs" */ log_dir: z.string().optional(), // ========================================================================= // Thinking Blocks // ========================================================================= /** * Preserve thinking blocks for Claude models using signature caching. * * When false (default): Thinking blocks are stripped for reliability. * When true: Full context preserved, but may encounter signature errors. * * Env override: OPENCODE_ANTIGRAVITY_KEEP_THINKING=1 * @default false */ keep_thinking: z.boolean().default(false), // ========================================================================= // Session Recovery // ========================================================================= /** * Enable automatic session recovery from tool_result_missing errors. * When enabled, shows a toast notification when recoverable errors occur. * * @default true */ session_recovery: z.boolean().default(true), /** * Automatically send a "continue" prompt after successful recovery. * Only applies when session_recovery is enabled. * * When false: Only shows toast notification, user must manually continue. * When true: Automatically sends "continue" to resume the session. * * @default false */ auto_resume: z.boolean().default(false), /** * Custom text to send when auto-resuming after recovery. * Only used when auto_resume is enabled. * * @default "continue" */ resume_text: z.string().default("continue"), // ========================================================================= // Signature Caching // ========================================================================= /** * Signature cache configuration for persisting thinking block signatures. * Only used when keep_thinking is enabled. */ signature_cache: SignatureCacheConfigSchema.optional(), // ========================================================================= // Empty Response Retry (ported from LLM-API-Key-Proxy) // ========================================================================= /** * Maximum retry attempts when Antigravity returns an empty response. * Empty responses occur when no candidates/choices are returned. * * @default 4 */ empty_response_max_attempts: z.number().min(1).max(10).default(4), /** * Delay in milliseconds between empty response retries. * * @default 2000 */ empty_response_retry_delay_ms: z.number().min(500).max(10000).default(2000), // ========================================================================= // Tool ID Recovery (ported from LLM-API-Key-Proxy) // ========================================================================= /** * Enable tool ID orphan recovery. * When tool responses have mismatched IDs (due to context compaction), * attempt to match them by function name or create placeholders. * * @default true */ tool_id_recovery: z.boolean().default(true), // ========================================================================= // Tool Hallucination Prevention (ported from LLM-API-Key-Proxy) // ========================================================================= /** * Enable tool hallucination prevention for Claude models. * When enabled, injects: * - Parameter signatures into tool descriptions * - System instruction with strict tool usage rules * * This helps prevent Claude from using parameter names from its training * data instead of the actual schema. * * @default true */ claude_tool_hardening: z.boolean().default(true), /** * Enable Claude prompt auto-caching by adding top-level cache_control when absent. * * @default false */ claude_prompt_auto_caching: z.boolean().default(false), // ========================================================================= // Proactive Token Refresh (ported from LLM-API-Key-Proxy) // ========================================================================= /** * Enable proactive background token refresh. * When enabled, tokens are refreshed in the background before they expire, * ensuring requests never block on token refresh. * * @default true */ proactive_token_refresh: z.boolean().default(true), /** * Seconds before token expiry to trigger proactive refresh. * Default is 30 minutes (1800 seconds). * * @default 1800 */ proactive_refresh_buffer_seconds: z.number().min(60).max(7200).default(1800), /** * Interval between proactive refresh checks in seconds. * Default is 5 minutes (300 seconds). * * @default 300 */ proactive_refresh_check_interval_seconds: z.number().min(30).max(1800).default(300), // ========================================================================= // Rate Limiting // ========================================================================= /** * Maximum time in seconds to wait when all accounts are rate-limited. * If the minimum wait time across all accounts exceeds this threshold, * the plugin fails fast with an error instead of hanging. * * Set to 0 to disable (wait indefinitely). * * @default 300 (5 minutes) */ max_rate_limit_wait_seconds: z.number().min(0).max(3600).default(300), /** * @deprecated Kept only for backward compatibility. * This flag is ignored at runtime. * Gemini requests always fall back between Antigravity and Gemini CLI quotas. * * @default false */ quota_fallback: z.boolean().default(false), /** * Prefer gemini-cli routing before Antigravity for Gemini models. * * When false (default): Antigravity is tried first, then gemini-cli. * When true: gemini-cli is tried first, then Antigravity. * * @default false */ cli_first: z.boolean().default(false), /** * Strategy for selecting accounts when making requests. * Env override: OPENCODE_ANTIGRAVITY_ACCOUNT_SELECTION_STRATEGY * @default "hybrid" */ account_selection_strategy: AccountSelectionStrategySchema.default('hybrid'), /** * Enable PID-based account offset for multi-session distribution. * * When enabled, different sessions (PIDs) will prefer different starting * accounts, which helps distribute load when running multiple parallel agents. * * When disabled (default), accounts start from the same index, which preserves * Anthropic's prompt cache across restarts (recommended for single-session use). * * Env override: OPENCODE_ANTIGRAVITY_PID_OFFSET_ENABLED=1 * @default false */ pid_offset_enabled: z.boolean().default(false), /** * Switch to another account immediately on first rate limit (after 1s delay). * When disabled, retries same account first, then switches on second rate limit. * * @default true */ switch_on_first_rate_limit: z.boolean().default(true), /** * Scheduling mode for rate limit behavior. * * - `cache_first`: Wait for same account to recover (preserves prompt cache). Default. * - `balance`: Switch account immediately on rate limit. Maximum availability. * - `performance_first`: Round-robin distribution for maximum throughput. * * Env override: OPENCODE_ANTIGRAVITY_SCHEDULING_MODE * @default "cache_first" */ scheduling_mode: SchedulingModeSchema.default('cache_first'), /** * Maximum seconds to wait for same account in cache_first mode. * If the account's rate limit reset time exceeds this, switch accounts. * * @default 60 */ max_cache_first_wait_seconds: z.number().min(5).max(300).default(60), /** * TTL in seconds for failure count expiration. * After this period of no failures, consecutiveFailures resets to 0. * This prevents old failures from permanently penalizing an account. * * @default 3600 (1 hour) */ failure_ttl_seconds: z.number().min(60).max(7200).default(3600), /** * Default retry delay in seconds when API doesn't return a retry-after header. * Lower values allow faster retries but may trigger more 429 errors. * * @default 60 */ default_retry_after_seconds: z.number().min(1).max(300).default(60), /** * Maximum backoff delay in seconds for exponential retry. * This caps how long the exponential backoff can grow. * * @default 60 */ max_backoff_seconds: z.number().min(5).max(300).default(60), /** * Maximum random delay in milliseconds before each API request. * Adds timing jitter to break predictable request cadence patterns. * Set to 0 to disable request jitter. * * @default 0 */ request_jitter_max_ms: z.number().min(0).max(5000).default(0), /** * Soft quota threshold percentage (1-100). * When an account's quota usage reaches this percentage, skip it during * account selection (same as if it were rate-limited). * * Example: 90 means skip account when 90% of quota is used (10% remaining). * Set to 100 to disable soft quota protection. * * @default 90 */ soft_quota_threshold_percent: z.number().min(1).max(100).default(90), /** * How often to refresh quota data in the background (in minutes). * Quota is refreshed opportunistically after successful API requests. * Set to 0 to disable automatic refresh (manual only via Check quotas). * * @default 15 */ quota_refresh_interval_minutes: z.number().min(0).max(60).default(15), /** * How long quota cache is considered fresh for threshold checks (in minutes). * After this time, cache is stale and account is allowed (fail-open). * * "auto" = derive from refresh interval: max(2 * refresh_interval, 10) * * @default "auto" */ soft_quota_cache_ttl_minutes: z.union([ z.literal("auto"), z.number().min(1).max(120) ]).default("auto"), // ========================================================================= // Health Score (used by hybrid strategy) // ========================================================================= health_score: z.object({ initial: z.number().min(0).max(100).default(70), success_reward: z.number().min(0).max(10).default(1), rate_limit_penalty: z.number().min(-50).max(0).default(-10), failure_penalty: z.number().min(-100).max(0).default(-20), recovery_rate_per_hour: z.number().min(0).max(20).default(2), min_usable: z.number().min(0).max(100).default(50), max_score: z.number().min(50).max(100).default(100), }).optional(), // ========================================================================= // Token Bucket (for hybrid strategy) // ========================================================================= token_bucket: z.object({ max_tokens: z.number().min(1).max(1000).default(50), regeneration_rate_per_minute: z.number().min(0.1).max(60).default(6), initial_tokens: z.number().min(1).max(1000).default(50), }).optional(), // ========================================================================= // Auto-Update // ========================================================================= /** * Enable automatic plugin updates. * @default true */ auto_update: z.boolean().default(true), }); export type AntigravityConfig = z.infer; export type SignatureCacheConfig = z.infer; /** * Default configuration values. */ export const DEFAULT_CONFIG: AntigravityConfig = { quiet_mode: false, toast_scope: 'root_only', debug: false, debug_tui: false, keep_thinking: false, session_recovery: true, auto_resume: true, resume_text: "continue", empty_response_max_attempts: 4, empty_response_retry_delay_ms: 2000, tool_id_recovery: true, claude_tool_hardening: true, claude_prompt_auto_caching: false, proactive_token_refresh: true, proactive_refresh_buffer_seconds: 1800, proactive_refresh_check_interval_seconds: 300, max_rate_limit_wait_seconds: 300, quota_fallback: false, cli_first: false, account_selection_strategy: 'hybrid', pid_offset_enabled: false, switch_on_first_rate_limit: true, scheduling_mode: 'cache_first', max_cache_first_wait_seconds: 60, failure_ttl_seconds: 3600, default_retry_after_seconds: 60, max_backoff_seconds: 60, request_jitter_max_ms: 0, soft_quota_threshold_percent: 90, quota_refresh_interval_minutes: 15, soft_quota_cache_ttl_minutes: "auto", auto_update: true, signature_cache: { enabled: true, memory_ttl_seconds: 3600, disk_ttl_seconds: 172800, write_interval_seconds: 60, }, health_score: { initial: 70, success_reward: 1, rate_limit_penalty: -10, failure_penalty: -20, recovery_rate_per_hour: 2, min_usable: 50, max_score: 100, }, token_bucket: { max_tokens: 50, regeneration_rate_per_minute: 6, initial_tokens: 50, }, }; ================================================ FILE: src/plugin/config/updater.test.ts ================================================ import { describe, test, expect, beforeEach, afterEach } from "vitest"; import * as fs from "node:fs"; import * as path from "node:path"; import * as os from "node:os"; import { updateOpencodeConfig } from "./updater"; import { OPENCODE_MODEL_DEFINITIONS } from "./models"; describe("updateOpencodeConfig", () => { let tempDir: string; let configPath: string; let originalXdgConfigHome: string | undefined; beforeEach(() => { originalXdgConfigHome = process.env.XDG_CONFIG_HOME; // Create a temporary directory for each test tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "opencode-test-")); configPath = path.join(tempDir, "opencode.json"); }); afterEach(() => { if (originalXdgConfigHome === undefined) { delete process.env.XDG_CONFIG_HOME; } else { process.env.XDG_CONFIG_HOME = originalXdgConfigHome; } // Clean up temp directory if (fs.existsSync(tempDir)) { fs.rmSync(tempDir, { recursive: true, force: true }); } }); test("creates new config with default structure when file does not exist", async () => { const result = await updateOpencodeConfig({ configPath }); expect(result.success).toBe(true); expect(result.configPath).toBe(configPath); expect(fs.existsSync(configPath)).toBe(true); // Verify written config has correct structure const writtenConfig = JSON.parse(fs.readFileSync(configPath, "utf-8")); expect(writtenConfig.$schema).toBe("https://opencode.ai/config.json"); expect(writtenConfig.plugin).toContain("opencode-antigravity-auth@latest"); expect(writtenConfig.provider?.google?.models).toBeDefined(); }); test("replaces existing google models with plugin models", async () => { const existingConfig = { $schema: "https://opencode.ai/config.json", plugin: ["opencode-antigravity-auth@latest"], provider: { google: { models: { "old-model": { name: "Old Model" }, }, }, }, }; fs.writeFileSync(configPath, JSON.stringify(existingConfig)); const result = await updateOpencodeConfig({ configPath }); expect(result.success).toBe(true); const writtenConfig = JSON.parse(fs.readFileSync(configPath, "utf-8")); // Old model should be replaced expect(writtenConfig.provider.google.models["old-model"]).toBeUndefined(); // New models should be present expect(writtenConfig.provider.google.models["antigravity-gemini-3-pro"]).toBeDefined(); expect(writtenConfig.provider.google.models["antigravity-claude-sonnet-4-6"]).toBeDefined(); }); test("preserves non-google provider sections", async () => { const existingConfig = { $schema: "https://opencode.ai/config.json", plugin: ["opencode-antigravity-auth@latest"], provider: { google: { models: { "old-model": {} }, }, anthropic: { apiKey: "secret-key", models: { "claude-3": {} }, }, openai: { models: { "gpt-4": {} }, }, }, }; fs.writeFileSync(configPath, JSON.stringify(existingConfig)); const result = await updateOpencodeConfig({ configPath }); expect(result.success).toBe(true); const writtenConfig = JSON.parse(fs.readFileSync(configPath, "utf-8")); // Non-google providers should be preserved expect(writtenConfig.provider.anthropic).toEqual(existingConfig.provider.anthropic); expect(writtenConfig.provider.openai).toEqual(existingConfig.provider.openai); }); test("preserves $schema and other top-level config keys", async () => { const existingConfig = { $schema: "https://opencode.ai/config.json", plugin: ["opencode-antigravity-auth@latest", "other-plugin"], theme: "dark", customSetting: { nested: true }, provider: { google: { models: {} }, }, }; fs.writeFileSync(configPath, JSON.stringify(existingConfig)); const result = await updateOpencodeConfig({ configPath }); expect(result.success).toBe(true); const writtenConfig = JSON.parse(fs.readFileSync(configPath, "utf-8")); expect(writtenConfig.$schema).toBe("https://opencode.ai/config.json"); expect(writtenConfig.plugin).toContain("other-plugin"); expect(writtenConfig.theme).toBe("dark"); expect(writtenConfig.customSetting).toEqual({ nested: true }); }); test("adds plugin to existing plugin array if not present", async () => { const existingConfig = { plugin: ["other-plugin"], provider: {}, }; fs.writeFileSync(configPath, JSON.stringify(existingConfig)); const result = await updateOpencodeConfig({ configPath }); expect(result.success).toBe(true); const writtenConfig = JSON.parse(fs.readFileSync(configPath, "utf-8")); expect(writtenConfig.plugin).toContain("opencode-antigravity-auth@latest"); expect(writtenConfig.plugin).toContain("other-plugin"); }); test("does not duplicate plugin if already present", async () => { const existingConfig = { plugin: ["opencode-antigravity-auth@latest", "other-plugin"], provider: {}, }; fs.writeFileSync(configPath, JSON.stringify(existingConfig)); const result = await updateOpencodeConfig({ configPath }); expect(result.success).toBe(true); const writtenConfig = JSON.parse(fs.readFileSync(configPath, "utf-8")); const pluginCount = writtenConfig.plugin.filter( (p: string) => p.includes("opencode-antigravity-auth") ).length; expect(pluginCount).toBe(1); }); test("does not duplicate plugin if different version present", async () => { const existingConfig = { plugin: ["opencode-antigravity-auth@beta", "other-plugin"], provider: {}, }; fs.writeFileSync(configPath, JSON.stringify(existingConfig)); const result = await updateOpencodeConfig({ configPath }); expect(result.success).toBe(true); const writtenConfig = JSON.parse(fs.readFileSync(configPath, "utf-8")); const pluginCount = writtenConfig.plugin.filter( (p: string) => p.includes("opencode-antigravity-auth") ).length; // Should not add another version if one exists expect(pluginCount).toBe(1); // Should preserve the existing version expect(writtenConfig.plugin).toContain("opencode-antigravity-auth@beta"); }); test("writes config with proper JSON formatting (2-space indent)", async () => { const result = await updateOpencodeConfig({ configPath }); expect(result.success).toBe(true); const writtenContent = fs.readFileSync(configPath, "utf-8"); // Should have newlines and 2-space indentation expect(writtenContent).toContain("\n"); expect(writtenContent).toMatch(/^\{\n {2}/); }); test("returns error result on invalid JSON in existing config", async () => { fs.writeFileSync(configPath, "{ invalid json }"); const result = await updateOpencodeConfig({ configPath }); expect(result.success).toBe(false); expect(result.error).toBeDefined(); }); test("includes all model definitions from OPENCODE_MODEL_DEFINITIONS", async () => { const result = await updateOpencodeConfig({ configPath }); expect(result.success).toBe(true); const writtenConfig = JSON.parse(fs.readFileSync(configPath, "utf-8")); const models = writtenConfig.provider.google.models; // Verify all models from OPENCODE_MODEL_DEFINITIONS are included for (const modelKey of Object.keys(OPENCODE_MODEL_DEFINITIONS)) { expect(models[modelKey]).toBeDefined(); } }); test("parses existing jsonc config files with comments and trailing commas", async () => { const jsoncPath = path.join(tempDir, "opencode.jsonc"); const existingJsoncConfig = `{ // Keep existing plugin "plugin": [ "other-plugin", ], "provider": { "google": { "region": "us-central1", }, }, }`; fs.writeFileSync(jsoncPath, existingJsoncConfig); const result = await updateOpencodeConfig({ configPath: jsoncPath }); expect(result.success).toBe(true); expect(result.configPath).toBe(jsoncPath); const writtenConfig = JSON.parse(fs.readFileSync(jsoncPath, "utf-8")); expect(writtenConfig.plugin).toContain("other-plugin"); expect(writtenConfig.plugin).toContain("opencode-antigravity-auth@latest"); expect(writtenConfig.provider.google.region).toBe("us-central1"); expect(writtenConfig.provider.google.models["antigravity-gemini-3-pro"]).toBeDefined(); }); test("prefers existing opencode.jsonc when using default config path", async () => { const opencodeDir = path.join(tempDir, "opencode"); const jsonPath = path.join(opencodeDir, "opencode.json"); const jsoncPath = path.join(opencodeDir, "opencode.jsonc"); fs.mkdirSync(opencodeDir, { recursive: true }); fs.writeFileSync(jsoncPath, JSON.stringify({ plugin: ["other-plugin"], provider: {} }, null, 2)); process.env.XDG_CONFIG_HOME = tempDir; const result = await updateOpencodeConfig(); expect(result.success).toBe(true); expect(result.configPath).toBe(jsoncPath); expect(fs.existsSync(jsonPath)).toBe(false); expect(fs.existsSync(jsoncPath)).toBe(true); }); test("creates parent directory if it does not exist", async () => { const nestedPath = path.join(tempDir, "nested", "dir", "opencode.json"); const result = await updateOpencodeConfig({ configPath: nestedPath }); expect(result.success).toBe(true); expect(fs.existsSync(nestedPath)).toBe(true); }); test("adds $schema if missing from existing config", async () => { const existingConfig = { plugin: ["opencode-antigravity-auth@latest"], provider: { google: {} }, }; fs.writeFileSync(configPath, JSON.stringify(existingConfig)); const result = await updateOpencodeConfig({ configPath }); expect(result.success).toBe(true); const writtenConfig = JSON.parse(fs.readFileSync(configPath, "utf-8")); expect(writtenConfig.$schema).toBe("https://opencode.ai/config.json"); }); test("preserves other google provider settings besides models", async () => { const existingConfig = { plugin: ["opencode-antigravity-auth@latest"], provider: { google: { apiKey: "test-key", models: { "old-model": {} }, customSetting: true, }, }, }; fs.writeFileSync(configPath, JSON.stringify(existingConfig)); const result = await updateOpencodeConfig({ configPath }); expect(result.success).toBe(true); const writtenConfig = JSON.parse(fs.readFileSync(configPath, "utf-8")); // Other google settings should be preserved expect(writtenConfig.provider.google.apiKey).toBe("test-key"); expect(writtenConfig.provider.google.customSetting).toBe(true); // But models should be replaced expect(writtenConfig.provider.google.models["old-model"]).toBeUndefined(); }); }); ================================================ FILE: src/plugin/config/updater.ts ================================================ /** * OpenCode configuration file updater. * * Updates ~/.config/opencode/opencode.json(c) with plugin models. */ import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs"; import { join, dirname } from "node:path"; import { homedir } from "node:os"; import { OPENCODE_MODEL_DEFINITIONS } from "./models"; // ============================================================================= // Types // ============================================================================= export interface UpdateConfigResult { success: boolean; configPath: string; error?: string; } export interface OpencodeConfig { $schema?: string; plugin?: string[]; provider?: { google?: { models?: Record; [key: string]: unknown; }; [key: string]: unknown; }; [key: string]: unknown; } export interface UpdateConfigOptions { /** Override the config file path (for testing) */ configPath?: string; } // ============================================================================= // Constants // ============================================================================= const PLUGIN_NAME = "opencode-antigravity-auth@latest"; const SCHEMA_URL = "https://opencode.ai/config.json"; const OPENCODE_JSON_FILENAME = "opencode.json"; const OPENCODE_JSONC_FILENAME = "opencode.jsonc"; function stripJsonCommentsAndTrailingCommas(json: string): string { return json .replace( /\\"|"(?:\\"|[^"])*"|(\/\/.*|\/\*[\s\S]*?\*\/)/g, (match: string, group: string | undefined) => (group ? "" : match) ) .replace(/,(\s*[}\]])/g, "$1"); } /** * Get the opencode config directory path. */ export function getOpencodeConfigDir(): string { const xdgConfig = process.env.XDG_CONFIG_HOME || join(homedir(), ".config"); return join(xdgConfig, "opencode"); } /** * Get the opencode config file path. * * Prefers opencode.jsonc when present so we update the active config file * instead of creating a new opencode.json. */ export function getOpencodeConfigPath(): string { const configDir = getOpencodeConfigDir(); const jsoncPath = join(configDir, OPENCODE_JSONC_FILENAME); const jsonPath = join(configDir, OPENCODE_JSON_FILENAME); if (existsSync(jsoncPath)) { return jsoncPath; } if (existsSync(jsonPath)) { return jsonPath; } return jsonPath; } // ============================================================================= // Main Function // ============================================================================= /** * Updates the opencode configuration file with plugin models. * * This function: * 1. Reads existing opencode.json/opencode.jsonc (or creates default structure) * 2. Replaces `provider.google.models` with plugin models * 3. Writes back to disk with proper formatting * * Preserves: * - $schema and other top-level config keys * - Non-google provider sections * - Other settings within google provider (except models) * * @param options - Optional configuration (e.g., custom configPath for testing) * @returns UpdateConfigResult with success status and path */ export async function updateOpencodeConfig( options: UpdateConfigOptions = {} ): Promise { const configPath = options.configPath ?? getOpencodeConfigPath(); try { let config: OpencodeConfig; // Read existing config or create default if (existsSync(configPath)) { const content = readFileSync(configPath, "utf-8"); config = JSON.parse(stripJsonCommentsAndTrailingCommas(content)) as OpencodeConfig; } else { // Create default config structure config = { $schema: SCHEMA_URL, plugin: [], provider: {}, }; } // Ensure $schema is set if (!config.$schema) { config.$schema = SCHEMA_URL; } // Ensure plugin array exists and contains our plugin if (!Array.isArray(config.plugin)) { config.plugin = []; } // Check if plugin is already in the list (any version) const hasPlugin = config.plugin.some((p) => p.includes("opencode-antigravity-auth") ); if (!hasPlugin) { config.plugin.push(PLUGIN_NAME); } // Ensure provider.google structure exists if (!config.provider) { config.provider = {}; } if (!config.provider.google) { config.provider.google = {}; } // Replace google models with plugin models config.provider.google.models = { ...OPENCODE_MODEL_DEFINITIONS }; // Ensure config directory exists const configDir = dirname(configPath); if (!existsSync(configDir)) { mkdirSync(configDir, { recursive: true }); } // Write config with proper formatting (2-space indent) writeFileSync(configPath, JSON.stringify(config, null, 2), "utf-8"); return { success: true, configPath, }; } catch (error) { return { success: false, configPath, error: error instanceof Error ? error.message : String(error), }; } } ================================================ FILE: src/plugin/core/streaming/index.ts ================================================ export * from './types'; export * from './transformer'; ================================================ FILE: src/plugin/core/streaming/transformer.ts ================================================ import type { SignatureStore, StreamingCallbacks, StreamingOptions, ThoughtBuffer, } from './types'; import { processImageData } from '../../image-saver'; /** * Simple string hash for thinking deduplication. * Uses DJB2-like algorithm. */ function hashString(str: string): string { let hash = 5381; for (let i = 0; i < str.length; i++) { hash = ((hash << 5) + hash) + str.charCodeAt(i); /* hash * 33 + c */ } return (hash >>> 0).toString(16); } export function createThoughtBuffer(): ThoughtBuffer { const buffer = new Map(); return { get: (index: number) => buffer.get(index), set: (index: number, text: string) => buffer.set(index, text), clear: () => buffer.clear(), }; } export function transformStreamingPayload( payload: string, transformThinkingParts?: (response: unknown) => unknown, ): string { return payload .split('\n') .map((line) => { if (!line.startsWith('data:')) { return line; } const json = line.slice(5).trim(); if (!json) { return line; } try { const parsed = JSON.parse(json) as { response?: unknown }; if (parsed.response !== undefined) { const transformed = transformThinkingParts ? transformThinkingParts(parsed.response) : parsed.response; return `data: ${JSON.stringify(transformed)}`; } } catch (_) {} return line; }) .join('\n'); } export function deduplicateThinkingText( response: unknown, sentBuffer: ThoughtBuffer, displayedThinkingHashes?: Set, ): unknown { if (!response || typeof response !== 'object') return response; const resp = response as Record; if (Array.isArray(resp.candidates)) { const newCandidates = resp.candidates.map((candidate: unknown, index: number) => { const cand = candidate as Record | null; if (!cand?.content) return candidate; const content = cand.content as Record; if (!Array.isArray(content.parts)) return candidate; const newParts = content.parts.map((part: unknown) => { const p = part as Record; // Handle image data - save to disk and return file path if (p.inlineData) { const inlineData = p.inlineData as Record; const result = processImageData({ mimeType: inlineData.mimeType as string | undefined, data: inlineData.data as string | undefined, }); if (result) { return { text: result }; } } if (p.thought === true || p.type === 'thinking') { const fullText = (p.text || p.thinking || '') as string; if (displayedThinkingHashes) { const hash = hashString(fullText); if (displayedThinkingHashes.has(hash)) { sentBuffer.set(index, fullText); return null; } displayedThinkingHashes.add(hash); } const sentText = sentBuffer.get(index) ?? ''; if (fullText.startsWith(sentText)) { const delta = fullText.slice(sentText.length); sentBuffer.set(index, fullText); if (delta) { return { ...p, text: delta, thinking: delta }; } return null; } sentBuffer.set(index, fullText); return part; } return part; }); const filteredParts = newParts.filter((p) => p !== null); return { ...cand, content: { ...content, parts: filteredParts }, }; }); return { ...resp, candidates: newCandidates }; } if (Array.isArray(resp.content)) { let thinkingIndex = 0; const newContent = resp.content.map((block: unknown) => { const b = block as Record | null; if (b?.type === 'thinking') { const fullText = (b.thinking || b.text || '') as string; if (displayedThinkingHashes) { const hash = hashString(fullText); if (displayedThinkingHashes.has(hash)) { sentBuffer.set(thinkingIndex, fullText); thinkingIndex++; return null; } displayedThinkingHashes.add(hash); } const sentText = sentBuffer.get(thinkingIndex) ?? ''; if (fullText.startsWith(sentText)) { const delta = fullText.slice(sentText.length); sentBuffer.set(thinkingIndex, fullText); thinkingIndex++; if (delta) { return { ...b, thinking: delta, text: delta }; } return null; } sentBuffer.set(thinkingIndex, fullText); thinkingIndex++; return block; } return block; }); const filteredContent = newContent.filter((b) => b !== null); return { ...resp, content: filteredContent }; } return response; } export function transformSseLine( line: string, signatureStore: SignatureStore, thoughtBuffer: ThoughtBuffer, sentThinkingBuffer: ThoughtBuffer, callbacks: StreamingCallbacks, options: StreamingOptions, debugState: { injected: boolean }, ): string { if (!line.startsWith('data:')) { return line; } const json = line.slice(5).trim(); if (!json) { return line; } try { const parsed = JSON.parse(json) as { response?: unknown }; if (parsed.response !== undefined) { if (options.cacheSignatures && options.signatureSessionKey) { cacheThinkingSignaturesFromResponse( parsed.response, options.signatureSessionKey, signatureStore, thoughtBuffer, callbacks.onCacheSignature, ); } let response: unknown = deduplicateThinkingText( parsed.response, sentThinkingBuffer, options.displayedThinkingHashes ); if (options.debugText && callbacks.onInjectDebug && !debugState.injected) { response = callbacks.onInjectDebug(response, options.debugText); debugState.injected = true; } // Note: onInjectSyntheticThinking removed - keep_thinking now uses debugText path const transformed = callbacks.transformThinkingParts ? callbacks.transformThinkingParts(response) : response; return `data: ${JSON.stringify(transformed)}`; } } catch (_) {} return line; } export function cacheThinkingSignaturesFromResponse( response: unknown, signatureSessionKey: string, signatureStore: SignatureStore, thoughtBuffer: ThoughtBuffer, onCacheSignature?: (sessionKey: string, text: string, signature: string) => void, ): void { if (!response || typeof response !== 'object') return; const resp = response as Record; if (Array.isArray(resp.candidates)) { resp.candidates.forEach((candidate: unknown, index: number) => { const cand = candidate as Record | null; if (!cand?.content) return; const content = cand.content as Record; if (!Array.isArray(content.parts)) return; content.parts.forEach((part: unknown) => { const p = part as Record; if (p.thought === true || p.type === 'thinking') { const text = (p.text || p.thinking || '') as string; if (text) { const current = thoughtBuffer.get(index) ?? ''; thoughtBuffer.set(index, current + text); } } if (p.thoughtSignature) { const fullText = thoughtBuffer.get(index) ?? ''; if (fullText) { const signature = p.thoughtSignature as string; onCacheSignature?.(signatureSessionKey, fullText, signature); signatureStore.set(signatureSessionKey, { text: fullText, signature }); } } }); }); } if (Array.isArray(resp.content)) { // Use thoughtBuffer to accumulate thinking text across SSE events // Claude streams thinking content and signature in separate events const CLAUDE_BUFFER_KEY = 0; // Use index 0 for Claude's single-stream content resp.content.forEach((block: unknown) => { const b = block as Record | null; if (b?.type === 'thinking') { const text = (b.thinking || b.text || '') as string; if (text) { const current = thoughtBuffer.get(CLAUDE_BUFFER_KEY) ?? ''; thoughtBuffer.set(CLAUDE_BUFFER_KEY, current + text); } } if (b?.signature) { const fullText = thoughtBuffer.get(CLAUDE_BUFFER_KEY) ?? ''; if (fullText) { const signature = b.signature as string; onCacheSignature?.(signatureSessionKey, fullText, signature); signatureStore.set(signatureSessionKey, { text: fullText, signature }); } } }); } } export function createStreamingTransformer( signatureStore: SignatureStore, callbacks: StreamingCallbacks, options: StreamingOptions = {}, ): TransformStream { const decoder = new TextDecoder(); const encoder = new TextEncoder(); let buffer = ''; const thoughtBuffer = createThoughtBuffer(); const sentThinkingBuffer = createThoughtBuffer(); const debugState = { injected: false }; let hasSeenUsageMetadata = false; return new TransformStream({ transform(chunk, controller) { buffer += decoder.decode(chunk, { stream: true }); const lines = buffer.split('\n'); buffer = lines.pop() || ''; for (const line of lines) { // Quick check for usage metadata presence in the raw line if (line.includes('usageMetadata')) { hasSeenUsageMetadata = true; } const transformedLine = transformSseLine( line, signatureStore, thoughtBuffer, sentThinkingBuffer, callbacks, options, debugState, ); controller.enqueue(encoder.encode(transformedLine + '\n')); } }, flush(controller) { buffer += decoder.decode(); if (buffer) { if (buffer.includes('usageMetadata')) { hasSeenUsageMetadata = true; } const transformedLine = transformSseLine( buffer, signatureStore, thoughtBuffer, sentThinkingBuffer, callbacks, options, debugState, ); controller.enqueue(encoder.encode(transformedLine)); } // Inject synthetic usage metadata if missing (fixes "Context % used: 0%" issue) if (!hasSeenUsageMetadata) { const syntheticUsage = { response: { usageMetadata: { promptTokenCount: 0, candidatesTokenCount: 0, totalTokenCount: 0, } } }; controller.enqueue(encoder.encode(`\ndata: ${JSON.stringify(syntheticUsage)}\n\n`)); } }, }); } ================================================ FILE: src/plugin/core/streaming/types.ts ================================================ export interface SignedThinking { text: string; signature: string; } export interface SignatureStore { get(sessionKey: string): SignedThinking | undefined; set(sessionKey: string, value: SignedThinking): void; has(sessionKey: string): boolean; delete(sessionKey: string): void; } export interface StreamingCallbacks { onCacheSignature?: (sessionKey: string, text: string, signature: string) => void; onInjectDebug?: (response: unknown, debugText: string) => unknown; // Note: onInjectSyntheticThinking removed - keep_thinking now unified with debug via debugText transformThinkingParts?: (parts: unknown) => unknown; } export interface StreamingOptions { signatureSessionKey?: string; debugText?: string; cacheSignatures?: boolean; displayedThinkingHashes?: Set; // Note: injectSyntheticThinking removed - keep_thinking now unified with debug via debugText } export interface ThoughtBuffer { get(index: number): string | undefined; set(index: number, text: string): void; clear(): void; } ================================================ FILE: src/plugin/cross-model-integration.test.ts ================================================ import { describe, it, expect } from "vitest"; import { sanitizeCrossModelPayload, getModelFamily, } from "./transform/cross-model-sanitizer"; describe("Cross-Model Session Integration", () => { describe("Gemini → Claude model switch with tool calls", () => { it("sanitizes Gemini thinking metadata when preparing Claude request", () => { const geminiSessionHistory = { contents: [ { role: "user", parts: [ { text: "Check disk space. Think about which filesystems are most utilized.", }, ], }, { role: "model", parts: [ { thought: true, text: "I need to analyze disk usage...", thoughtSignature: "EsgQCsUQAXLI2nybuafAE150LGTo2r78fakesig", }, { functionCall: { name: "bash", args: { command: "df -h" } }, metadata: { google: { thoughtSignature: "EsgQCsUQAXLI2nybuafAE150LGTo2r78fakesig", }, }, }, ], }, { role: "function", parts: [ { functionResponse: { name: "bash", response: { output: "Filesystem Size Used Avail Use%..." }, }, }, ], }, { role: "model", parts: [{ text: "The root filesystem is 62% utilized..." }], }, ], }; const payload = { model: "claude-opus-4-6-thinking-medium", ...geminiSessionHistory, contents: [ ...geminiSessionHistory.contents, { role: "user", parts: [{ text: "Now check memory usage with free -h" }], }, ], }; const result = sanitizeCrossModelPayload(payload, { targetModel: "claude-opus-4-6-thinking-medium", }); const sanitized = result.payload as typeof payload; const modelParts = sanitized.contents[1]!.parts; expect( (modelParts[0] as Record).thoughtSignature ).toBeUndefined(); expect( (modelParts[1] as Record).metadata ).toBeUndefined(); expect( (modelParts[1] as Record & { functionCall: { name: string } }).functionCall.name ).toBe("bash"); expect(result.modified).toBe(true); expect(result.signaturesStripped).toBeGreaterThan(0); }); it("preserves non-signature metadata", () => { const payload = { contents: [ { role: "model", parts: [ { functionCall: { name: "read", args: { path: "/etc/passwd" } }, metadata: { google: { thoughtSignature: "should-be-stripped", groundingMetadata: { searchQueries: ["test"] }, searchEntryPoint: { renderedContent: "test" }, }, cache_control: { type: "ephemeral" }, }, }, ], }, ], }; const result = sanitizeCrossModelPayload(payload, { targetModel: "claude-sonnet-4", preserveNonSignatureMetadata: true, }); const sanitized = result.payload as typeof payload; const partMeta = (sanitized.contents[0]!.parts![0] as Record) .metadata as Record; const googleMeta = partMeta.google as Record; expect(googleMeta.thoughtSignature).toBeUndefined(); expect(googleMeta.groundingMetadata).toEqual({ searchQueries: ["test"] }); expect(googleMeta.searchEntryPoint).toEqual({ renderedContent: "test" }); expect( (partMeta.cache_control as Record).type ).toBe("ephemeral"); }); it("handles the exact bug reproduction scenario from issue", () => { const payload = { model: "claude-opus-4-6-thinking-medium", contents: [ { role: "user", parts: [ { text: "Check how much disk space is available using df -h. Think about which filesystems are most utilized.", }, ], }, { role: "model", parts: [ { thought: true, text: "Let me analyze the disk space request. The user wants to see disk usage and understand filesystem utilization patterns...", thoughtSignature: "EsgQCsUQAXLI2nybuafAE150LGTo2r78VeryLongSignatureStringThatExceeds50Characters", }, { functionCall: { name: "Bash", args: { command: "df -h", description: "Check disk space availability", }, }, metadata: { google: { thoughtSignature: "EsgQCsUQAXLI2nybuafAE150LGTo2r78VeryLongSignatureStringThatExceeds50Characters", }, }, }, ], }, { role: "function", parts: [ { functionResponse: { name: "Bash", response: { output: "Filesystem Size Used Avail Use% Mounted on\noverlay 59G 37G 20G 65% /\ntmpfs 64M 0 64M 0% /dev\n", }, }, }, ], }, { role: "model", parts: [ { text: "Based on the disk space analysis, the root overlay filesystem is 65% utilized with 37G used out of 59G total.", }, ], }, { role: "user", parts: [{ text: "Now check memory usage with free -h" }], }, ], }; const result = sanitizeCrossModelPayload(payload, { targetModel: "claude-opus-4-6-thinking-medium", }); const sanitized = result.payload as typeof payload; const thinkingPart = sanitized.contents[1]!.parts![0] as Record; expect(thinkingPart.thoughtSignature).toBeUndefined(); expect(thinkingPart.thought).toBe(true); expect(thinkingPart.text).toContain("analyze the disk space"); const toolPart = sanitized.contents[1]!.parts![1] as Record; expect(toolPart.metadata).toBeUndefined(); expect( (toolPart.functionCall as Record).name ).toBe("Bash"); expect(result.signaturesStripped).toBe(2); }); }); describe("Claude → Gemini model switch", () => { it("sanitizes Claude thinking blocks when preparing Gemini request", () => { const payload = { extra_body: { messages: [ { role: "assistant", content: [ { type: "thinking", thinking: "Analyzing the request...", signature: "claude-signature-abc123VeryLongSignatureStringThatExceeds50Characters", }, { type: "tool_use", id: "tool_1", name: "bash", input: { command: "ls" }, }, ], }, ], }, }; const result = sanitizeCrossModelPayload(payload, { targetModel: "gemini-3-pro-low", }); const sanitized = result.payload as typeof payload; const content = sanitized.extra_body!.messages![0]!.content; const thinkingBlock = content.find( (c: Record) => c.type === "thinking" ) as Record; expect(thinkingBlock.signature).toBeUndefined(); expect(thinkingBlock.thinking).toBe("Analyzing the request..."); const toolBlock = content.find( (c: Record) => c.type === "tool_use" ) as Record; expect(toolBlock.name).toBe("bash"); }); it("strips redacted_thinking blocks", () => { const payload = { messages: [ { role: "assistant", content: [ { type: "redacted_thinking", data: "encrypted_data_here", signature: "redacted-sig-VeryLongSignatureStringThatExceeds50Characters", }, { type: "text", text: "Here is my response" }, ], }, ], }; const result = sanitizeCrossModelPayload(payload, { targetModel: "gemini-3-flash", }); const sanitized = result.payload as typeof payload; const redactedBlock = sanitized.messages![0]!.content![0] as Record< string, unknown >; expect(redactedBlock.signature).toBeUndefined(); expect(redactedBlock.type).toBe("redacted_thinking"); }); }); describe("Same model family - no sanitization needed", () => { it("preserves Gemini signatures when staying on Gemini", () => { const payload = { contents: [ { role: "model", parts: [ { thought: true, text: "thinking...", thoughtSignature: "valid-gemini-sig", }, ], }, ], }; const result = sanitizeCrossModelPayload(payload, { targetModel: "gemini-3-flash", }); const sanitized = result.payload as typeof payload; expect( (sanitized.contents![0]!.parts![0] as Record) .thoughtSignature ).toBe("valid-gemini-sig"); expect(result.modified).toBe(false); }); it("preserves Claude signatures when staying on Claude", () => { const payload = { messages: [ { role: "assistant", content: [ { type: "thinking", thinking: "analyzing...", signature: "valid-claude-sig", }, ], }, ], }; const result = sanitizeCrossModelPayload(payload, { targetModel: "claude-opus-4-6-thinking-low", }); const sanitized = result.payload as typeof payload; expect( (sanitized.messages![0]!.content![0] as Record).signature ).toBe("valid-claude-sig"); expect(result.modified).toBe(false); }); }); describe("Model family detection", () => { it("correctly identifies Gemini models", () => { expect(getModelFamily("gemini-3-pro-low")).toBe("gemini"); expect(getModelFamily("gemini-3-flash")).toBe("gemini"); expect(getModelFamily("gemini-2.5-pro")).toBe("gemini"); expect(getModelFamily("gemini-3-pro-high")).toBe("gemini"); }); it("correctly identifies Claude models", () => { expect(getModelFamily("claude-opus-4-6-thinking-medium")).toBe("claude"); expect(getModelFamily("claude-sonnet-4-6")).toBe("claude"); expect(getModelFamily("claude-sonnet-4")).toBe("claude"); expect(getModelFamily("claude-3-opus")).toBe("claude"); }); it("returns unknown for unrecognized models", () => { expect(getModelFamily("gpt-4")).toBe("unknown"); expect(getModelFamily("llama-3")).toBe("unknown"); }); }); describe("Edge cases", () => { it("handles empty payloads", () => { const result = sanitizeCrossModelPayload( {}, { targetModel: "claude-sonnet-4" } ); expect(result.modified).toBe(false); expect(result.signaturesStripped).toBe(0); }); it("handles null/undefined parts gracefully", () => { const payload = { contents: [ { role: "user", parts: null }, { role: "model", parts: undefined }, { role: "model" }, ], }; const result = sanitizeCrossModelPayload(payload, { targetModel: "claude-sonnet-4", }); expect(result.modified).toBe(false); }); it("handles wrapped requests array (batch format)", () => { const payload = { requests: [ { contents: [ { role: "model", parts: [ { thoughtSignature: "sig1", thought: true, text: "thinking", }, ], }, ], }, { contents: [ { role: "model", parts: [ { metadata: { google: { thoughtSignature: "sig2" } }, functionCall: { name: "test" }, }, ], }, ], }, ], }; const result = sanitizeCrossModelPayload(payload, { targetModel: "claude-sonnet-4-6", }); const sanitized = result.payload as typeof payload; expect( (sanitized.requests![0]!.contents![0]!.parts![0] as Record) .thoughtSignature ).toBeUndefined(); expect( (sanitized.requests![1]!.contents![0]!.parts![0] as Record) .metadata ).toBeUndefined(); expect(result.signaturesStripped).toBe(2); }); it("handles unknown target model by skipping sanitization", () => { const payload = { contents: [ { role: "model", parts: [{ thoughtSignature: "sig", thought: true, text: "hi" }], }, ], }; const result = sanitizeCrossModelPayload(payload, { targetModel: "gpt-4-turbo", }); const sanitized = result.payload as typeof payload; expect( (sanitized.contents![0]!.parts![0] as Record) .thoughtSignature ).toBe("sig"); expect(result.modified).toBe(false); }); }); }); ================================================ FILE: src/plugin/debug.test.ts ================================================ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" import { DEFAULT_CONFIG } from "./config" const { ensureGitignoreSyncMock } = vi.hoisted(() => ({ ensureGitignoreSyncMock: vi.fn(), })) vi.mock("./storage", () => ({ ensureGitignoreSync: ensureGitignoreSyncMock, })) describe("debug sink policy", () => { let originalDebugEnv: string | undefined let originalDebugTuiEnv: string | undefined beforeEach(() => { vi.resetModules() originalDebugEnv = process.env.OPENCODE_ANTIGRAVITY_DEBUG originalDebugTuiEnv = process.env.OPENCODE_ANTIGRAVITY_DEBUG_TUI delete process.env.OPENCODE_ANTIGRAVITY_DEBUG delete process.env.OPENCODE_ANTIGRAVITY_DEBUG_TUI ensureGitignoreSyncMock.mockReset() }) afterEach(() => { if (originalDebugEnv === undefined) { delete process.env.OPENCODE_ANTIGRAVITY_DEBUG } else { process.env.OPENCODE_ANTIGRAVITY_DEBUG = originalDebugEnv } if (originalDebugTuiEnv === undefined) { delete process.env.OPENCODE_ANTIGRAVITY_DEBUG_TUI } else { process.env.OPENCODE_ANTIGRAVITY_DEBUG_TUI = originalDebugTuiEnv } }) it("keeps debug_tui independent from debug in config", async () => { const { initializeDebug, isDebugEnabled, isDebugTuiEnabled, getLogFilePath } = await import("./debug") initializeDebug({ ...DEFAULT_CONFIG, debug: false, debug_tui: true, }) expect(isDebugEnabled()).toBe(false) expect(isDebugTuiEnabled()).toBe(true) expect(getLogFilePath()).toBeUndefined() }) it("keeps debug_tui independent from debug in env fallback", async () => { process.env.OPENCODE_ANTIGRAVITY_DEBUG = "0" process.env.OPENCODE_ANTIGRAVITY_DEBUG_TUI = "1" const { isDebugEnabled, isDebugTuiEnabled, getLogFilePath } = await import("./debug") expect(isDebugEnabled()).toBe(false) expect(isDebugTuiEnabled()).toBe(true) expect(getLogFilePath()).toBeUndefined() }) it("keeps file debug enabled without TUI when only debug is true", async () => { const { initializeDebug, isDebugEnabled, isDebugTuiEnabled, getLogFilePath } = await import("./debug") initializeDebug({ ...DEFAULT_CONFIG, debug: true, debug_tui: false, log_dir: "/tmp/opencode-antigravity-debug-tests", }) expect(isDebugEnabled()).toBe(true) expect(isDebugTuiEnabled()).toBe(false) expect(getLogFilePath()).toContain("antigravity-debug-") }) }) ================================================ FILE: src/plugin/debug.ts ================================================ import { createWriteStream, mkdirSync, readdirSync, statSync, unlinkSync } from "node:fs"; import { join } from "node:path"; import { env } from "node:process"; import { homedir } from "node:os"; import type { AntigravityConfig } from "./config"; import { deriveDebugPolicy, formatAccountContextLabel, formatAccountLabel, formatBodyPreviewForLog, formatErrorForLog, isTruthyFlag, truncateTextForLog, } from "./logging-utils"; import { ensureGitignoreSync } from "./storage"; const MAX_BODY_PREVIEW_CHARS = 12000; const MAX_BODY_LOG_CHARS = 50000; export const DEBUG_MESSAGE_PREFIX = "[opencode-antigravity-auth debug]"; // ============================================================================= // Debug State (lazily initialized with config) // ============================================================================= interface DebugState { debugEnabled: boolean; debugTuiEnabled: boolean; logFilePath: string | undefined; logWriter: (line: string) => void; } let debugState: DebugState | null = null; /** * Get the OS-specific config directory. */ function getConfigDir(): string { const platform = process.platform; if (platform === "win32") { return join(env.APPDATA || join(homedir(), "AppData", "Roaming"), "opencode"); } const xdgConfig = env.XDG_CONFIG_HOME || join(homedir(), ".config"); return join(xdgConfig, "opencode"); } /** * Returns the logs directory, creating it if needed. */ function getLogsDir(customLogDir?: string): string { const logsDir = customLogDir || join(getConfigDir(), "antigravity-logs"); try { mkdirSync(logsDir, { recursive: true }); } catch { // Directory may already exist or we don't have permission } return logsDir; } /** * Builds a timestamped log file path. */ function createLogFilePath(customLogDir?: string): string { const logsDir = getLogsDir(customLogDir); cleanupOldLogs(logsDir, 25); const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); return join(logsDir, `antigravity-debug-${timestamp}.log`); } /** * Cleans up old log files, keeping only the most recent maxFiles. */ function cleanupOldLogs(logsDir: string, maxFiles: number): void { try { const files = readdirSync(logsDir) .filter((file) => file.startsWith("antigravity-debug-") && file.endsWith(".log")) .map((file) => join(logsDir, file)); if (files.length <= maxFiles) { return; } const sortedFiles = files .map((file) => ({ file, mtime: statSync(file).mtimeMs, })) .sort((a, b) => b.mtime - a.mtime); for (let i = maxFiles; i < sortedFiles.length; i++) { try { unlinkSync(sortedFiles[i]!.file); } catch { // Ignore deletion errors } } } catch { // Ignore directory read errors } } /** * Creates a log writer function that writes to a file. */ function createLogWriter(filePath?: string): (line: string) => void { if (!filePath) { return () => {}; } try { const stream = createWriteStream(filePath, { flags: "a" }); stream.on("error", () => {}); return (line: string) => { const timestamp = new Date().toISOString(); const formatted = `[${timestamp}] ${line}`; stream.write(`${formatted}\n`); }; } catch { return () => {}; } } /** * Initialize or reinitialize debug state with the given config. * Call this once at plugin startup after loading config. */ export function initializeDebug(config: AntigravityConfig): void { // Config takes precedence, but env var can force enable for debugging const envDebugFlag = env.OPENCODE_ANTIGRAVITY_DEBUG ?? ""; const { debugEnabled } = deriveDebugPolicy({ configDebug: config.debug, configDebugTui: config.debug_tui, envDebugFlag, envDebugTuiFlag: env.OPENCODE_ANTIGRAVITY_DEBUG_TUI, }); const debugTuiEnabled = config.debug_tui || isTruthyFlag(env.OPENCODE_ANTIGRAVITY_DEBUG_TUI); const logFilePath = debugEnabled ? createLogFilePath(config.log_dir) : undefined; const logWriter = createLogWriter(logFilePath); if (debugEnabled) { ensureGitignoreSync(getConfigDir()); } debugState = { debugEnabled, debugTuiEnabled, logFilePath, logWriter, }; } /** * Get the current debug state, initializing with defaults if needed. * This allows the module to work even before initializeDebug is called. */ function getDebugState(): DebugState { if (!debugState) { // Fallback to env-based initialization for backward compatibility const { debugEnabled } = deriveDebugPolicy({ configDebug: false, configDebugTui: false, envDebugFlag: env.OPENCODE_ANTIGRAVITY_DEBUG, envDebugTuiFlag: env.OPENCODE_ANTIGRAVITY_DEBUG_TUI, }); const debugTuiEnabled = isTruthyFlag(env.OPENCODE_ANTIGRAVITY_DEBUG_TUI); const logFilePath = debugEnabled ? createLogFilePath() : undefined; const logWriter = createLogWriter(logFilePath); debugState = { debugEnabled, debugTuiEnabled, logFilePath, logWriter, }; } return debugState; } // ============================================================================= // Public API // ============================================================================= export function isDebugEnabled(): boolean { return getDebugState().debugEnabled; } export function isDebugTuiEnabled(): boolean { return getDebugState().debugTuiEnabled; } export function getLogFilePath(): string | undefined { return getDebugState().logFilePath; } export interface AntigravityDebugContext { id: string; streaming: boolean; startedAt: number; } interface AntigravityDebugRequestMeta { originalUrl: string; resolvedUrl: string; method?: string; headers?: HeadersInit; body?: BodyInit | null; streaming: boolean; projectId?: string; } interface AntigravityDebugResponseMeta { body?: string; note?: string; error?: unknown; headersOverride?: HeadersInit; } let requestCounter = 0; /** * Begins a debug trace for an Antigravity request. */ export function startAntigravityDebugRequest(meta: AntigravityDebugRequestMeta): AntigravityDebugContext | null { const state = getDebugState(); if (!state.debugEnabled) { return null; } const id = `ANTIGRAVITY-${++requestCounter}`; const method = meta.method ?? "GET"; logDebug(`[Antigravity Debug ${id}] pid=${process.pid} ${method} ${meta.resolvedUrl}`); if (meta.originalUrl && meta.originalUrl !== meta.resolvedUrl) { logDebug(`[Antigravity Debug ${id}] Original URL: ${meta.originalUrl}`); } if (meta.projectId) { logDebug(`[Antigravity Debug ${id}] Project: ${meta.projectId}`); } logDebug(`[Antigravity Debug ${id}] Streaming: ${meta.streaming ? "yes" : "no"}`); logDebug(`[Antigravity Debug ${id}] Headers: ${JSON.stringify(maskHeaders(meta.headers))}`); const bodyPreview = formatBodyPreviewForLog(meta.body, MAX_BODY_PREVIEW_CHARS); if (bodyPreview) { logDebug(`[Antigravity Debug ${id}] Body Preview: ${bodyPreview}`); } return { id, streaming: meta.streaming, startedAt: Date.now() }; } /** * Logs response details for a previously started debug trace. */ export function logAntigravityDebugResponse( context: AntigravityDebugContext | null | undefined, response: Response, meta: AntigravityDebugResponseMeta = {}, ): void { const state = getDebugState(); if (!state.debugEnabled || !context) { return; } const durationMs = Date.now() - context.startedAt; logDebug( `[Antigravity Debug ${context.id}] Response ${response.status} ${response.statusText} (${durationMs}ms)`, ); logDebug( `[Antigravity Debug ${context.id}] Response Headers: ${JSON.stringify( maskHeaders(meta.headersOverride ?? response.headers), )}`, ); if (meta.note) { logDebug(`[Antigravity Debug ${context.id}] Note: ${meta.note}`); } if (meta.error) { logDebug(`[Antigravity Debug ${context.id}] Error: ${formatErrorForLog(meta.error)}`); } if (meta.body) { logDebug( `[Antigravity Debug ${context.id}] Response Body Preview: ${truncateTextForLog(meta.body, MAX_BODY_PREVIEW_CHARS)}`, ); } } /** * Obscures sensitive headers and returns a plain object for logging. */ function maskHeaders(headers?: HeadersInit | Headers): Record { if (!headers) { return {}; } const result: Record = {}; const parsed = headers instanceof Headers ? headers : new Headers(headers); parsed.forEach((value, key) => { if (key.toLowerCase() === "authorization") { result[key] = "[redacted]"; } else { result[key] = value; } }); return result; } /** * Writes a single debug line using the configured writer. */ function logDebug(line: string): void { getDebugState().logWriter(line); } function runWithDebugEnabled(action: () => void): void { if (!getDebugState().debugEnabled) return; action(); } export interface AccountDebugInfo { index: number; email?: string; family: string; totalAccounts: number; rateLimitState?: { claude?: number; gemini?: number }; } export function logAccountContext(label: string, info: AccountDebugInfo): void { runWithDebugEnabled(() => { const accountLabel = formatAccountContextLabel(info.email, info.index); const indexLabel = info.index >= 0 ? `${info.index + 1}/${info.totalAccounts}` : `-/${info.totalAccounts}`; let rateLimitInfo = ""; if (info.rateLimitState && Object.keys(info.rateLimitState).length > 0) { const now = Date.now(); const activeRateLimits: Record = {}; for (const [key, resetTime] of Object.entries(info.rateLimitState)) { if (typeof resetTime === "number" && resetTime > now) { const remainingSec = Math.ceil((resetTime - now) / 1000); activeRateLimits[key] = `${remainingSec}s`; } } if (Object.keys(activeRateLimits).length > 0) { rateLimitInfo = ` rateLimits=${JSON.stringify(activeRateLimits)}`; } } logDebug(`[Account] ${label}: ${accountLabel} (${indexLabel}) family=${info.family}${rateLimitInfo}`); }); } export function logRateLimitEvent( accountIndex: number, email: string | undefined, family: string, status: number, retryAfterMs: number, bodyInfo: { message?: string; quotaResetTime?: string; retryDelayMs?: number | null; reason?: string }, ): void { runWithDebugEnabled(() => { const accountLabel = formatAccountLabel(email, accountIndex); logDebug(`[RateLimit] ${status} on ${accountLabel} family=${family} retryAfterMs=${retryAfterMs}`); if (bodyInfo.message) { logDebug(`[RateLimit] message: ${bodyInfo.message}`); } if (bodyInfo.quotaResetTime) { logDebug(`[RateLimit] quotaResetTime: ${bodyInfo.quotaResetTime}`); } if (bodyInfo.retryDelayMs !== undefined && bodyInfo.retryDelayMs !== null) { logDebug(`[RateLimit] body retryDelayMs: ${bodyInfo.retryDelayMs}`); } if (bodyInfo.reason) { logDebug(`[RateLimit] reason: ${bodyInfo.reason}`); } }); } export function logRateLimitSnapshot( family: string, accounts: Array<{ index: number; email?: string; rateLimitResetTimes?: { claude?: number; gemini?: number } }>, ): void { runWithDebugEnabled(() => { const now = Date.now(); const entries = accounts.map((account) => { const label = formatAccountLabel(account.email, account.index); const reset = account.rateLimitResetTimes?.[family as "claude" | "gemini"]; if (typeof reset !== "number") { return `${label}=ready`; } const remaining = Math.max(0, reset - now); const seconds = Math.ceil(remaining / 1000); return `${label}=wait ${seconds}s`; }); logDebug(`[RateLimit] snapshot family=${family} ${entries.join(" | ")}`); }); } export async function logResponseBody( context: AntigravityDebugContext | null | undefined, response: Response, status: number, ): Promise { const state = getDebugState(); if (!state.debugEnabled || !context) return undefined; try { const text = await response.clone().text(); const preview = truncateTextForLog(text, MAX_BODY_LOG_CHARS); logDebug(`[Antigravity Debug ${context.id}] Response Body (${status}): ${preview}`); return text; } catch (e) { logDebug(`[Antigravity Debug ${context.id}] Failed to read response body: ${formatErrorForLog(e)}`); return undefined; } } export function logModelFamily(url: string, extractedModel: string | null, family: string): void { runWithDebugEnabled(() => { logDebug(`[ModelFamily] url=${url} model=${extractedModel ?? "unknown"} family=${family}`); }); } export function debugLogToFile(message: string): void { runWithDebugEnabled(() => { logDebug(message); }); } /** * Logs a toast message to the debug file. * This helps correlate what the user saw with debug events. */ export function logToast(message: string, variant: "info" | "warning" | "success" | "error"): void { runWithDebugEnabled(() => { const variantLabel = variant.toUpperCase(); logDebug(`[Toast/${variantLabel}] ${message}`); }); } /** * Logs retry attempt information. * @param maxAttempts - Use -1 for unlimited retries */ export function logRetryAttempt( attempt: number, maxAttempts: number, reason: string, delayMs?: number, ): void { runWithDebugEnabled(() => { const delayInfo = delayMs !== undefined ? ` delay=${delayMs}ms` : ""; const maxInfo = maxAttempts < 0 ? "∞" : maxAttempts.toString(); logDebug(`[Retry] Attempt ${attempt}/${maxInfo} reason=${reason}${delayInfo}`); }); } /** * Logs cache hit/miss information from response usage metadata. */ export function logCacheStats( model: string, cacheReadTokens: number, cacheWriteTokens: number, totalInputTokens: number, ): void { runWithDebugEnabled(() => { const cacheHitRate = totalInputTokens > 0 ? Math.round((cacheReadTokens / totalInputTokens) * 100) : 0; const status = cacheReadTokens > 0 ? "HIT" : (cacheWriteTokens > 0 ? "WRITE" : "MISS"); logDebug(`[Cache] ${status} model=${model} read=${cacheReadTokens} write=${cacheWriteTokens} total=${totalInputTokens} hitRate=${cacheHitRate}%`); }); } /** * Logs quota status for an account. */ export function logQuotaStatus( accountEmail: string | undefined, accountIndex: number, quotaPercent: number, family?: string, ): void { runWithDebugEnabled(() => { const accountLabel = formatAccountLabel(accountEmail, accountIndex); const familyInfo = family ? ` family=${family}` : ""; const status = quotaPercent <= 0 ? "EXHAUSTED" : quotaPercent < 20 ? "LOW" : "OK"; logDebug(`[Quota] ${accountLabel} remaining=${quotaPercent.toFixed(1)}% status=${status}${familyInfo}`); }); } /** * Logs background quota fetch events. */ export function logQuotaFetch( event: "start" | "complete" | "error", accountCount?: number, details?: string, ): void { runWithDebugEnabled(() => { const countInfo = accountCount !== undefined ? ` accounts=${accountCount}` : ""; const detailsInfo = details ? ` ${details}` : ""; logDebug(`[QuotaFetch] ${event.toUpperCase()}${countInfo}${detailsInfo}`); }); } /** * Logs which model is being used for a request. */ export function logModelUsed( requestedModel: string, actualModel: string, accountEmail?: string, ): void { runWithDebugEnabled(() => { const accountInfo = accountEmail ? ` account=${accountEmail}` : ""; if (requestedModel !== actualModel) { logDebug(`[Model] requested=${requestedModel} actual=${actualModel}${accountInfo}`); } else { logDebug(`[Model] ${actualModel}${accountInfo}`); } }); } ================================================ FILE: src/plugin/errors.ts ================================================ /** * Custom error types for opencode-antigravity-auth plugin. * * Ported from LLM-API-Key-Proxy for robust error handling. */ /** * Error thrown when Antigravity returns an empty response after retry attempts. * * Empty responses can occur when: * - The model has no candidates/choices * - The response body is empty or malformed * - A temporary service issue prevents generation */ export class EmptyResponseError extends Error { readonly provider: string; readonly model: string; readonly attempts: number; constructor( provider: string, model: string, attempts: number, message?: string, ) { super( message ?? `The model returned an empty response after ${attempts} attempts. ` + `This may indicate a temporary service issue. Please try again.`, ); this.name = "EmptyResponseError"; this.provider = provider; this.model = model; this.attempts = attempts; } } /** * Error thrown when tool ID matching fails and cannot be recovered. */ export class ToolIdMismatchError extends Error { readonly expectedIds: string[]; readonly foundIds: string[]; constructor(expectedIds: string[], foundIds: string[], message?: string) { super( message ?? `Tool ID mismatch: expected [${expectedIds.join(", ")}] but found [${foundIds.join(", ")}]`, ); this.name = "ToolIdMismatchError"; this.expectedIds = expectedIds; this.foundIds = foundIds; } } ================================================ FILE: src/plugin/fingerprint.ts ================================================ /** * Device Fingerprint Generator for Rate Limit Mitigation * * Ported from antigravity-claude-proxy PR #170 * https://github.com/badrisnarayanan/antigravity-claude-proxy/pull/170 * * Generates randomized device fingerprints to help distribute API usage * across different apparent device identities. */ import * as crypto from "node:crypto"; import * as os from "node:os"; import { getAntigravityVersion } from "../constants"; const OS_VERSIONS: Record = { darwin: ["10.15.7", "11.6.8", "12.6.3", "13.5.2", "14.2.1", "14.5"], win32: ["10.0.19041", "10.0.19042", "10.0.19043", "10.0.22000", "10.0.22621", "10.0.22631"], linux: ["5.15.0", "5.19.0", "6.1.0", "6.2.0", "6.5.0", "6.6.0"], }; const ARCHITECTURES = ["x64", "arm64"]; const IDE_TYPES = [ "ANTIGRAVITY", ] as const; const PLATFORMS = [ "WINDOWS", "MACOS", ] as const; const SDK_CLIENTS = [ "google-cloud-sdk vscode_cloudshelleditor/0.1", "google-cloud-sdk vscode/1.86.0", "google-cloud-sdk vscode/1.87.0", "google-cloud-sdk vscode/1.96.0", ]; export interface ClientMetadata { ideType: string; platform: string; pluginType: string; } export interface Fingerprint { deviceId: string; sessionToken: string; userAgent: string; apiClient: string; clientMetadata: ClientMetadata; createdAt: number; /** @deprecated Kept for backward compat with stored fingerprints */ quotaUser?: string; } /** * Fingerprint version for history tracking. * Stores a snapshot of a fingerprint with metadata about when/why it was saved. */ export interface FingerprintVersion { fingerprint: Fingerprint; timestamp: number; reason: 'initial' | 'regenerated' | 'restored'; } /** Maximum number of fingerprint versions to keep per account */ export const MAX_FINGERPRINT_HISTORY = 5; export interface FingerprintHeaders { "User-Agent": string; } function randomFrom(arr: readonly T[]): T { return arr[Math.floor(Math.random() * arr.length)]!; } function generateDeviceId(): string { return crypto.randomUUID(); } function generateSessionToken(): string { return crypto.randomBytes(16).toString("hex"); } /** * Generate a randomized device fingerprint. * Each fingerprint represents a unique "device" identity. */ export function generateFingerprint(): Fingerprint { const platform = randomFrom(["darwin", "win32"] as const); const arch = randomFrom(ARCHITECTURES); const osVersion = randomFrom(OS_VERSIONS[platform] ?? OS_VERSIONS.darwin!); const matchingPlatform = platform === "win32" ? "WINDOWS" : "MACOS"; return { deviceId: generateDeviceId(), sessionToken: generateSessionToken(), userAgent: `antigravity/${getAntigravityVersion()} ${platform}/${arch}`, apiClient: randomFrom(SDK_CLIENTS), clientMetadata: { ideType: randomFrom(IDE_TYPES), platform: matchingPlatform, pluginType: "GEMINI", }, createdAt: Date.now(), }; } /** * Collect fingerprint based on actual current system. * Uses real OS info instead of randomized values. */ export function collectCurrentFingerprint(): Fingerprint { const platform = os.platform(); const arch = os.arch(); const matchingPlatform = platform === "win32" ? "WINDOWS" : "MACOS"; return { deviceId: generateDeviceId(), sessionToken: generateSessionToken(), userAgent: `antigravity/${getAntigravityVersion()} ${platform}/${arch}`, apiClient: "google-cloud-sdk vscode_cloudshelleditor/0.1", clientMetadata: { ideType: "ANTIGRAVITY", platform: matchingPlatform, pluginType: "GEMINI", }, createdAt: Date.now(), }; } /** * Update the version in a fingerprint's userAgent to match the current runtime version. * Called after version fetcher resolves so saved fingerprints always carry the latest version. * Returns true if the userAgent was changed. */ export function updateFingerprintVersion(fingerprint: Fingerprint): boolean { const currentVersion = getAntigravityVersion(); const versionPattern = /^(antigravity\/)([\d.]+)/; const match = fingerprint.userAgent.match(versionPattern); if (!match || match[2] === currentVersion) { return false; } fingerprint.userAgent = fingerprint.userAgent.replace(versionPattern, `$1${currentVersion}`); return true; } /** * Build HTTP headers from a fingerprint object. * These headers are used to identify the "device" making API requests. */ export function buildFingerprintHeaders(fingerprint: Fingerprint | null): Partial { if (!fingerprint) { return {}; } return { "User-Agent": fingerprint.userAgent, }; } /** * Session-level fingerprint instance. * Generated once at module load, persists for the lifetime of the process. */ let sessionFingerprint: Fingerprint | null = null; /** * Get or create the session fingerprint. * Returns the same fingerprint for all calls within a session. */ export function getSessionFingerprint(): Fingerprint { if (!sessionFingerprint) { sessionFingerprint = generateFingerprint(); } return sessionFingerprint; } /** * Regenerate the session fingerprint. * Call this to get a fresh identity (e.g., after rate limiting). */ export function regenerateSessionFingerprint(): Fingerprint { sessionFingerprint = generateFingerprint(); return sessionFingerprint; } ================================================ FILE: src/plugin/image-saver.ts ================================================ /** * Image Saving Utility * * Handles saving generated images to disk and returning file paths. */ import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; /** * Default directory for saving generated images. * Uses ~/.opencode/generated-images/ */ function getImageOutputDir(): string { const homeDir = os.homedir(); const outputDir = path.join(homeDir, '.opencode', 'generated-images'); // Create directory if it doesn't exist if (!fs.existsSync(outputDir)) { fs.mkdirSync(outputDir, { recursive: true }); } return outputDir; } /** * Generate a unique filename for the image. */ function generateImageFilename(mimeType: string): string { const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); const random = Math.random().toString(36).substring(2, 8); // Determine extension from mime type let ext = 'png'; if (mimeType.includes('jpeg') || mimeType.includes('jpg')) { ext = 'jpg'; } else if (mimeType.includes('gif')) { ext = 'gif'; } else if (mimeType.includes('webp')) { ext = 'webp'; } return `image-${timestamp}-${random}.${ext}`; } /** * Save base64 image data to disk and return the file path. * * @param base64Data - The base64-encoded image data * @param mimeType - The MIME type of the image (e.g., "image/jpeg") * @returns The absolute path to the saved image file */ export function saveImageToDisk(base64Data: string, mimeType: string): string { try { const outputDir = getImageOutputDir(); const filename = generateImageFilename(mimeType); const filePath = path.join(outputDir, filename); // Decode base64 and write to file const buffer = Buffer.from(base64Data, 'base64'); fs.writeFileSync(filePath, buffer); return filePath; } catch (error) { // If saving fails, return empty string (caller will fall back to base64) console.error('[image-saver] Failed to save image:', error); return ''; } } /** * Process inlineData and return either a file path or base64 data URL. * Attempts to save to disk first, falls back to base64 if saving fails. * * @param inlineData - Object containing mimeType and base64 data * @returns Markdown image string with either file path or data URL */ export function processImageData(inlineData: { mimeType?: string; data?: string }): string | null { const mimeType = inlineData.mimeType || 'image/png'; const data = inlineData.data; if (!data) { return null; } // Try to save to disk first const filePath = saveImageToDisk(data, mimeType); if (filePath) { // Successfully saved - return file path with open command hint return `![Generated Image](${filePath})\n\nImage saved to: \`${filePath}\`\n\nTo view: \`open "${filePath}"\``; } // Fall back to base64 data URL return `![Generated Image](data:${mimeType};base64,${data})`; } ================================================ FILE: src/plugin/logger.test.ts ================================================ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" import { DEFAULT_CONFIG } from "./config" import type { PluginClient } from "./types" const { ensureGitignoreSyncMock } = vi.hoisted(() => ({ ensureGitignoreSyncMock: vi.fn(), })) vi.mock("./storage", () => ({ ensureGitignoreSync: ensureGitignoreSyncMock, })) describe("logger sink routing", () => { beforeEach(() => { vi.resetModules() ensureGitignoreSyncMock.mockReset() }) afterEach(async () => { const { initializeDebug } = await import("./debug") initializeDebug(DEFAULT_CONFIG) }) it("routes logs to TUI when debug_tui is enabled without file debug", async () => { const { initializeDebug } = await import("./debug") const { createLogger, initLogger } = await import("./logger") initializeDebug({ ...DEFAULT_CONFIG, debug: false, debug_tui: true, }) const appLog = vi.fn().mockResolvedValue(undefined) const client = { app: { log: appLog, }, } as unknown as PluginClient initLogger(client) createLogger("request").debug("thinking-resolution", { status: 429 }) expect(appLog).toHaveBeenCalledTimes(1) expect(appLog).toHaveBeenCalledWith({ body: { service: "antigravity.request", level: "debug", message: "thinking-resolution", extra: { status: 429 }, }, }) }) it("does not route to TUI when only file debug is enabled", async () => { const { initializeDebug } = await import("./debug") const { createLogger, initLogger } = await import("./logger") initializeDebug({ ...DEFAULT_CONFIG, debug: true, debug_tui: false, log_dir: "/tmp/opencode-antigravity-logger-tests", }) const appLog = vi.fn().mockResolvedValue(undefined) const client = { app: { log: appLog, }, } as unknown as PluginClient initLogger(client) createLogger("request").debug("file-only") expect(appLog).not.toHaveBeenCalled() }) }) ================================================ FILE: src/plugin/logger.ts ================================================ /** * Structured Logger for Antigravity Plugin * * Logging behavior: * - debug controls file logs only (via debug.ts) * - debug_tui controls TUI log panel only * - either sink can be enabled independently * - OPENCODE_ANTIGRAVITY_CONSOLE_LOG=1 → console output (independent of debug flags) */ import type { PluginClient } from "./types"; import { isDebugTuiEnabled } from "./debug"; import { isTruthyFlag, writeConsoleLog, } from "./logging-utils"; type LogLevel = "debug" | "info" | "warn" | "error"; const ENV_CONSOLE_LOG = "OPENCODE_ANTIGRAVITY_CONSOLE_LOG"; export interface Logger { debug(message: string, extra?: Record): void; info(message: string, extra?: Record): void; warn(message: string, extra?: Record): void; error(message: string, extra?: Record): void; } let _client: PluginClient | null = null; /** * Check if console logging is enabled via environment variable. */ function isConsoleLogEnabled(): boolean { return isTruthyFlag(process.env[ENV_CONSOLE_LOG]); } /** * Initialize the logger with the plugin client. * Must be called during plugin initialization to enable TUI logging. */ export function initLogger(client: PluginClient): void { _client = client; } /** * Create a logger instance for a specific module. * * @param module - The module name (e.g., "refresh-queue", "transform.claude") * @returns Logger instance with debug, info, warn, error methods * * @example * ```typescript * const log = createLogger("refresh-queue"); * log.debug("Checking tokens", { count: 5 }); * log.warn("Token expired", { accountIndex: 0 }); * ``` */ export function createLogger(module: string): Logger { const service = `antigravity.${module}`; const log = (level: LogLevel, message: string, extra?: Record): void => { // TUI logging: controlled only by debug_tui policy if (isDebugTuiEnabled()) { const app = _client?.app; if (app && typeof app.log === "function") { app .log({ body: { service, level, message, extra }, }) .catch(() => { // Silently ignore logging errors }); } } // Console fallback: when env var is set (independent of debug flags) if (isConsoleLogEnabled()) { const prefix = `[${service}]`; const args = extra ? [prefix, message, extra] : [prefix, message]; writeConsoleLog(level, ...args); } // If neither TUI nor console logging is enabled, log is silently discarded }; return { debug: (message, extra) => log("debug", message, extra), info: (message, extra) => log("info", message, extra), warn: (message, extra) => log("warn", message, extra), error: (message, extra) => log("error", message, extra), }; } ================================================ FILE: src/plugin/logging-utils.test.ts ================================================ import { describe, expect, it, vi } from "vitest" import { deriveDebugPolicy, formatAccountContextLabel, formatAccountLabel, formatBodyPreviewForLog, formatErrorForLog, truncateTextForLog, writeConsoleLog, } from "./logging-utils" describe("deriveDebugPolicy", () => { it("keeps debug_tui disabled when debug is disabled", () => { const policy = deriveDebugPolicy({ configDebug: false, configDebugTui: true, envDebugFlag: "", envDebugTuiFlag: "1", }) expect(policy.debugEnabled).toBe(false) expect(policy.debugTuiEnabled).toBe(false) expect(policy.verboseEnabled).toBe(false) expect(policy.debugLevel).toBe(0) }) it("supports verbose mode override when debug config is enabled", () => { const policy = deriveDebugPolicy({ configDebug: true, configDebugTui: false, envDebugFlag: "verbose", envDebugTuiFlag: "", }) expect(policy.debugEnabled).toBe(true) expect(policy.debugTuiEnabled).toBe(false) expect(policy.verboseEnabled).toBe(true) expect(policy.debugLevel).toBe(2) }) }) describe("format helpers", () => { it("formats account labels consistently", () => { expect(formatAccountLabel("person@example.com", 4)).toBe("person@example.com") expect(formatAccountLabel(undefined, 1)).toBe("Account 2") expect(formatAccountContextLabel(undefined, -1)).toBe("All accounts") expect(formatAccountContextLabel(undefined, 0)).toBe("Account 1") }) it("formats errors defensively", () => { expect(formatErrorForLog(new Error("boom"))).toContain("boom") expect(formatErrorForLog({ code: 401 })).toBe('{"code":401}') const circular: { self?: unknown } = {} circular.self = circular expect(formatErrorForLog(circular)).toContain("[object Object]") }) it("truncates long text with metadata", () => { const longText = "x".repeat(12) expect(truncateTextForLog(longText, 5)).toBe("xxxxx... (truncated 7 chars)") expect(truncateTextForLog("short", 10)).toBe("short") }) it("formats body previews safely", () => { expect(formatBodyPreviewForLog("abcdef", 3)).toBe("abc... (truncated 3 chars)") expect(formatBodyPreviewForLog(new URLSearchParams({ q: "value" }), 100)).toBe("q=value") expect(formatBodyPreviewForLog(new Uint8Array([1, 2]), 100)).toBe("[Uint8Array payload omitted]") }) }) describe("writeConsoleLog", () => { it("routes to the level-specific console method", () => { const debugSpy = vi.spyOn(console, "debug").mockImplementation(() => {}) const infoSpy = vi.spyOn(console, "info").mockImplementation(() => {}) const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}) const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}) writeConsoleLog("debug", "dbg") writeConsoleLog("info", "inf") writeConsoleLog("warn", "wrn") writeConsoleLog("error", "err") expect(debugSpy).toHaveBeenCalledWith("dbg") expect(infoSpy).toHaveBeenCalledWith("inf") expect(warnSpy).toHaveBeenCalledWith("wrn") expect(errorSpy).toHaveBeenCalledWith("err") debugSpy.mockRestore() infoSpy.mockRestore() warnSpy.mockRestore() errorSpy.mockRestore() }) }) ================================================ FILE: src/plugin/logging-utils.ts ================================================ export type LogLevel = "debug" | "info" | "warn" | "error" export interface DebugPolicyInput { configDebug: boolean configDebugTui: boolean envDebugFlag?: string envDebugTuiFlag?: string } export interface DebugPolicy { debugLevel: number debugEnabled: boolean debugTuiEnabled: boolean verboseEnabled: boolean } export function isTruthyFlag(flag?: string): boolean { return flag === "1" || flag?.toLowerCase() === "true" } export function parseDebugLevel(flag: string): number { const trimmed = flag.trim() if (trimmed === "2" || trimmed === "verbose") return 2 if (trimmed === "1" || trimmed === "true") return 1 return 0 } export function deriveDebugPolicy(input: DebugPolicyInput): DebugPolicy { const envDebugFlag = input.envDebugFlag ?? "" const debugLevel = input.configDebug ? envDebugFlag === "2" || envDebugFlag === "verbose" ? 2 : 1 : parseDebugLevel(envDebugFlag) const debugEnabled = debugLevel >= 1 const verboseEnabled = debugLevel >= 2 const debugTuiEnabled = debugEnabled && (input.configDebugTui || isTruthyFlag(input.envDebugTuiFlag)) return { debugLevel, debugEnabled, debugTuiEnabled, verboseEnabled, } } export function formatAccountLabel(email: string | undefined, accountIndex: number): string { return email || `Account ${accountIndex + 1}` } export function formatAccountContextLabel(email: string | undefined, accountIndex: number): string { if (email) { return email } if (accountIndex >= 0) { return `Account ${accountIndex + 1}` } return "All accounts" } export function formatErrorForLog(error: unknown): string { if (error instanceof Error) { return error.stack ?? error.message } try { return JSON.stringify(error) } catch { return String(error) } } export function truncateTextForLog(text: string, maxChars: number): string { if (text.length <= maxChars) { return text } return `${text.slice(0, maxChars)}... (truncated ${text.length - maxChars} chars)` } export function formatBodyPreviewForLog( body: BodyInit | null | undefined, maxChars: number, ): string | undefined { if (body == null) { return undefined } if (typeof body === "string") { return truncateTextForLog(body, maxChars) } if (body instanceof URLSearchParams) { return truncateTextForLog(body.toString(), maxChars) } if (typeof Blob !== "undefined" && body instanceof Blob) { return `[Blob size=${body.size}]` } if (typeof FormData !== "undefined" && body instanceof FormData) { return "[FormData payload omitted]" } return `[${body.constructor?.name ?? typeof body} payload omitted]` } export function writeConsoleLog(level: LogLevel, ...args: unknown[]): void { switch (level) { case "debug": console.debug(...args) break case "info": console.info(...args) break case "warn": console.warn(...args) break case "error": console.error(...args) break } } ================================================ FILE: src/plugin/model-specific-quota.test.ts ================================================ import { describe, it, expect, beforeEach } from "vitest"; import { AccountManager } from "./accounts"; import type { OAuthAuthDetails } from "./types"; describe("Model-specific Gemini quota", () => { let manager: AccountManager; const auth: OAuthAuthDetails = { type: "oauth", refresh: "test-refresh", access: "test-access", expires: Date.now() + 3600000, }; beforeEach(() => { manager = new AccountManager(auth); }); it("blocks only the specific Gemini model when markRateLimited is called with a model", () => { const account = manager.getCurrentAccountForFamily("gemini")!; const modelPro = "gemini-1.5-pro"; const modelFlash = "gemini-1.5-flash"; // Mark gemini-1.5-pro as rate limited on antigravity manager.markRateLimited(account, 60000, "gemini", "antigravity", modelPro); // gemini-1.5-pro should be rate limited for antigravity expect(manager.isRateLimitedForHeaderStyle(account, "gemini", "antigravity", modelPro)).toBe(true); // gemini-1.5-flash should NOT be rate limited for antigravity expect(manager.isRateLimitedForHeaderStyle(account, "gemini", "antigravity", modelFlash)).toBe(false); // General gemini (no model) should NOT be rate limited expect(manager.isRateLimitedForHeaderStyle(account, "gemini", "antigravity")).toBe(false); }); it("falls back to gemini-cli only for the specific model", () => { const account = manager.getCurrentAccountForFamily("gemini")!; const modelPro = "gemini-1.5-pro"; const modelFlash = "gemini-1.5-flash"; // Mark gemini-1.5-pro as rate limited on antigravity manager.markRateLimited(account, 60000, "gemini", "antigravity", modelPro); // Available header style for Pro should be gemini-cli expect(manager.getAvailableHeaderStyle(account, "gemini", modelPro)).toBe("gemini-cli"); // Available header style for Flash should still be antigravity expect(manager.getAvailableHeaderStyle(account, "gemini", modelFlash)).toBe("antigravity"); }); it("returns null when all header styles are exhausted for the specific model on a single account", () => { const manager2 = new AccountManager(auth); const account = manager2.getCurrentAccountForFamily("gemini")!; const modelPro = "gemini-1.5-pro"; const modelFlash = "gemini-1.5-flash"; manager2.markRateLimited(account, 60000, "gemini", "antigravity", modelPro); manager2.markRateLimited(account, 60000, "gemini", "gemini-cli", modelPro); // No other account available, so returns null for the rate-limited model expect(manager2.getCurrentOrNextForFamily("gemini", modelPro)).toBeNull(); // Flash should still return the same account since it's not rate-limited const flashAccount = manager2.getCurrentOrNextForFamily("gemini", modelFlash); expect(flashAccount).toBe(account); }); it("base family rate limit blocks all models in that family", () => { const account = manager.getCurrentAccountForFamily("gemini")!; const modelPro = "gemini-1.5-pro"; // Mark base gemini-antigravity as rate limited manager.markRateLimited(account, 60000, "gemini", "antigravity"); // All Gemini models should now be blocked for antigravity on this account expect(manager.isRateLimitedForHeaderStyle(account, "gemini", "antigravity", modelPro)).toBe(true); expect(manager.isRateLimitedForHeaderStyle(account, "gemini", "antigravity", "gemini-1.5-flash")).toBe(true); }); }); ================================================ FILE: src/plugin/persist-account-pool.test.ts ================================================ /** * Tests for persistAccountPool function * * Issue #89: Multi-account login overwrites existing accounts * Root cause: loadAccounts() returning null is treated as "no accounts" * even when the file exists but couldn't be read (permissions, corruption, etc.) * * @see https://github.com/NoeFabris/opencode-antigravity-auth/issues/89 */ import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; import { promises as fs } from "node:fs"; import * as storageModule from "./storage"; import type { AccountStorageV4, AccountMetadataV3 } from "./storage"; vi.mock("proper-lockfile", () => ({ default: { lock: vi.fn().mockResolvedValue(vi.fn().mockResolvedValue(undefined)), }, })); vi.mock("node:fs", async () => { const actual = await vi.importActual("node:fs"); return { ...actual, promises: { readFile: vi.fn(), writeFile: vi.fn(), mkdir: vi.fn().mockResolvedValue(undefined), access: vi.fn().mockResolvedValue(undefined), unlink: vi.fn(), rename: vi.fn().mockResolvedValue(undefined), }, }; }); function createMockAccount(overrides: Partial = {}): AccountMetadataV3 { return { email: "test@example.com", refreshToken: "test-refresh-token", projectId: "test-project-id", managedProjectId: "test-managed-project-id", addedAt: Date.now() - 10000, lastUsed: Date.now(), ...overrides, }; } function createMockStorage(accounts: AccountMetadataV3[], activeIndex = 0): AccountStorageV4 { return { version: 4, accounts, activeIndex, }; } describe("loadAccounts", () => { beforeEach(() => { vi.clearAllMocks(); }); afterEach(() => { vi.restoreAllMocks(); }); describe("file not found (ENOENT)", () => { it("returns null when file does not exist", async () => { const error = new Error("ENOENT") as NodeJS.ErrnoException; error.code = "ENOENT"; vi.mocked(fs.readFile).mockRejectedValue(error); const result = await storageModule.loadAccounts(); expect(result).toBeNull(); }); }); describe("file exists with valid data", () => { it("returns storage for valid V3 file", async () => { const mockStorage = createMockStorage([createMockAccount()]); vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify(mockStorage)); const result = await storageModule.loadAccounts(); expect(result).not.toBeNull(); expect(result?.version).toBe(4); expect(result?.accounts).toHaveLength(1); }); it("returns storage with multiple accounts", async () => { const mockStorage = createMockStorage([ createMockAccount({ email: "user1@example.com", refreshToken: "token1" }), createMockAccount({ email: "user2@example.com", refreshToken: "token2" }), ]); vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify(mockStorage)); const result = await storageModule.loadAccounts(); expect(result?.accounts).toHaveLength(2); expect(result?.accounts[0]?.email).toBe("user1@example.com"); expect(result?.accounts[1]?.email).toBe("user2@example.com"); }); it("preserves activeIndex from storage", async () => { const mockStorage = createMockStorage([ createMockAccount({ email: "user1@example.com" }), createMockAccount({ email: "user2@example.com" }), ], 1); vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify(mockStorage)); const result = await storageModule.loadAccounts(); expect(result?.activeIndex).toBe(1); }); }); describe("error handling - THE BUG (Issue #89)", () => { /** * THIS IS THE BUG: loadAccounts returns null for ANY error, not just ENOENT. * The caller (persistAccountPool) cannot distinguish between: * - File doesn't exist (safe to create new) * - File exists but couldn't be read (DANGEROUS - would overwrite!) */ it("returns null on permission denied (EACCES)", async () => { const error = new Error("EACCES") as NodeJS.ErrnoException; error.code = "EACCES"; vi.mocked(fs.readFile).mockRejectedValue(error); const result = await storageModule.loadAccounts(); expect(result).toBeNull(); }); it("returns null on JSON parse error", async () => { vi.mocked(fs.readFile).mockResolvedValue("{ invalid json }}}"); const result = await storageModule.loadAccounts(); expect(result).toBeNull(); }); it("returns null on invalid storage format", async () => { vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify({ version: 4, notAccounts: [] })); const result = await storageModule.loadAccounts(); expect(result).toBeNull(); }); it("returns null on unknown version", async () => { vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify({ version: 999, accounts: [] })); const result = await storageModule.loadAccounts(); expect(result).toBeNull(); }); }); describe("migration", () => { it("migrates V2 to V3 successfully", async () => { const v2Storage = { version: 2, accounts: [ { refreshToken: "token1", addedAt: Date.now() - 10000, lastUsed: Date.now(), }, ], activeIndex: 0, }; vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify(v2Storage)); vi.mocked(fs.writeFile).mockResolvedValue(undefined); const result = await storageModule.loadAccounts(); expect(result?.version).toBe(4); expect(result?.accounts).toHaveLength(1); }); }); }); describe("saveAccounts", () => { beforeEach(() => { vi.clearAllMocks(); }); it("saves valid storage to disk", async () => { vi.mocked(fs.writeFile).mockResolvedValue(undefined); vi.mocked(fs.mkdir).mockResolvedValue(undefined); const storage = createMockStorage([createMockAccount()]); await storageModule.saveAccounts(storage); expect(fs.writeFile).toHaveBeenCalledTimes(1); const writtenContent = vi.mocked(fs.writeFile).mock.calls[0]?.[1]; expect(writtenContent).toBeDefined(); const parsed = JSON.parse(writtenContent as string); expect(parsed.version).toBe(4); expect(parsed.accounts).toHaveLength(1); }); }); /** * Tests for the expected behavior of persistAccountPool * * NOTE: persistAccountPool is currently a private function in plugin.ts. * These tests document the EXPECTED behavior after the fix. * To run these tests, persistAccountPool should be exported. */ describe("persistAccountPool behavior (Issue #89)", () => { beforeEach(() => { vi.clearAllMocks(); vi.useFakeTimers(); vi.setSystemTime(new Date("2026-01-01T12:00:00Z")); }); afterEach(() => { vi.useRealTimers(); }); describe("merging behavior (replaceAll=false)", () => { it.todo("merges new account with existing accounts"); it.todo("deduplicates by email, keeping the newest token"); it.todo("deduplicates by refresh token when email not available"); it.todo("preserves activeIndex when adding new accounts"); it.todo("updates lastUsed timestamp for existing accounts"); }); describe("fresh start behavior (replaceAll=true)", () => { it.todo("replaces all existing accounts with new ones"); it.todo("resets activeIndex to 0"); it.todo("ignores existing accounts file"); }); describe("THE BUG: error handling when loadAccounts fails (Issue #89)", () => { /** * Current buggy behavior: * 1. User has accounts saved in ~/.config/opencode/antigravity-accounts.json * 2. loadAccounts() fails (permission error, JSON parse error, etc.) * 3. loadAccounts() returns null * 4. persistAccountPool treats null as "no accounts exist" * 5. New account REPLACES existing accounts instead of merging * * Expected behavior after fix: * 1. loadAccounts() should distinguish ENOENT from other errors * 2. persistAccountPool should throw/warn when file exists but can't be read * 3. User should be prompted about potential data loss */ it.todo("should NOT overwrite accounts when loadAccounts returns null due to permission error"); it.todo("should throw error when file exists but cannot be read"); it.todo("should prompt user when existing accounts may be lost"); it.todo("should only treat ENOENT as 'safe to create new file'"); }); }); /** * Tests for TUI flow integration (Issue #89) * * The user's logs showed they went through TUI flow, not CLI flow. * TUI flow calls persistAccountPool with replaceAll=false, * which should merge accounts but doesn't when loadAccounts fails. */ describe("TUI flow integration (Issue #89)", () => { describe("account persistence after OAuth", () => { it.todo("should merge new account with existing accounts in TUI flow"); it.todo("should show warning when existing accounts cannot be loaded"); it.todo("should ask user for confirmation before potentially overwriting accounts"); }); describe("authorize function behavior", () => { it.todo("TUI flow (inputs falsy) should check for existing accounts"); it.todo("should handle loadAccounts returning null gracefully"); }); }); /** * Regression tests to ensure the fix doesn't break normal operation */ describe("regression tests", () => { beforeEach(() => { vi.clearAllMocks(); }); describe("first-time user experience", () => { it("should work correctly when no accounts file exists (ENOENT)", async () => { const error = new Error("ENOENT") as NodeJS.ErrnoException; error.code = "ENOENT"; vi.mocked(fs.readFile).mockRejectedValue(error); const result = await storageModule.loadAccounts(); expect(result).toBeNull(); vi.mocked(fs.writeFile).mockResolvedValue(undefined); vi.mocked(fs.mkdir).mockResolvedValue(undefined); const newStorage = createMockStorage([createMockAccount()]); await expect(storageModule.saveAccounts(newStorage)).resolves.not.toThrow(); }); }); describe("normal multi-account workflow", () => { it("should load existing accounts correctly", async () => { const existingStorage = createMockStorage([ createMockAccount({ email: "existing@example.com" }), ]); vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify(existingStorage)); const result = await storageModule.loadAccounts(); expect(result).not.toBeNull(); expect(result?.accounts).toHaveLength(1); expect(result?.accounts[0]?.email).toBe("existing@example.com"); }); it("should preserve all accounts when saving", async () => { const enoent = new Error("ENOENT") as NodeJS.ErrnoException; enoent.code = "ENOENT"; vi.mocked(fs.readFile).mockRejectedValue(enoent); vi.mocked(fs.writeFile).mockResolvedValue(undefined); vi.mocked(fs.mkdir).mockResolvedValue(undefined); const storage = createMockStorage([ createMockAccount({ email: "user1@example.com", refreshToken: "token1" }), createMockAccount({ email: "user2@example.com", refreshToken: "token2" }), createMockAccount({ email: "user3@example.com", refreshToken: "token3" }), ]); await storageModule.saveAccounts(storage); expect(fs.writeFile).toHaveBeenCalledTimes(2); const tmpWriteCall = vi.mocked(fs.writeFile).mock.calls.find( (call) => (call[0] as string).includes(".tmp") ); expect(tmpWriteCall).toBeDefined(); const parsed = JSON.parse(tmpWriteCall![1] as string); expect(parsed.accounts).toHaveLength(3); const gitignoreWriteCall = vi.mocked(fs.writeFile).mock.calls.find( (call) => (call[0] as string).includes(".gitignore") ); expect(gitignoreWriteCall).toBeDefined(); }); }); }); /** * Proposed fix validation tests * * These tests validate enhanced error handling behavior. */ describe("proposed fix validation", () => { describe("loadAccounts should distinguish error types", () => { it.todo("should return { error: 'ENOENT' } when file doesn't exist"); it.todo("should return { error: 'PERMISSION_DENIED' } on EACCES"); it.todo("should return { error: 'PARSE_ERROR' } on invalid JSON"); it.todo("should return { error: 'INVALID_FORMAT' } on schema mismatch"); }); describe("persistAccountPool should handle errors safely", () => { it.todo("should throw AccountFileUnreadableError when file exists but can't be read"); it.todo("should include recovery instructions in error message"); }); describe("user prompts for data safety", () => { it.todo("should prompt user when accounts file exists but is unreadable"); it.todo("should offer options: (r)etry, (b)ackup and continue, (a)bort"); }); }); ================================================ FILE: src/plugin/project.ts ================================================ import { getAntigravityHeaders, ANTIGRAVITY_ENDPOINT_FALLBACKS, ANTIGRAVITY_LOAD_ENDPOINTS, ANTIGRAVITY_DEFAULT_PROJECT_ID, } from "../constants"; import { formatRefreshParts, parseRefreshParts } from "./auth"; import { createLogger } from "./logger"; import type { OAuthAuthDetails, ProjectContextResult } from "./types"; const log = createLogger("project"); const projectContextResultCache = new Map(); const projectContextPendingCache = new Map>(); const CODE_ASSIST_METADATA = { ideType: "ANTIGRAVITY", platform: process.platform === "win32" ? "WINDOWS" : "MACOS", pluginType: "GEMINI", } as const; interface AntigravityUserTier { id?: string; isDefault?: boolean; userDefinedCloudaicompanionProject?: boolean; } interface LoadCodeAssistPayload { cloudaicompanionProject?: string | { id?: string }; currentTier?: { id?: string; }; allowedTiers?: AntigravityUserTier[]; } interface OnboardUserPayload { done?: boolean; response?: { cloudaicompanionProject?: { id?: string; }; }; } function buildMetadata(projectId?: string): Record { const metadata: Record = { ideType: CODE_ASSIST_METADATA.ideType, platform: CODE_ASSIST_METADATA.platform, pluginType: CODE_ASSIST_METADATA.pluginType, }; if (projectId) { metadata.duetProject = projectId; } return metadata; } /** * Selects the default tier ID from the allowed tiers list. */ function getDefaultTierId(allowedTiers?: AntigravityUserTier[]): string | undefined { if (!allowedTiers || allowedTiers.length === 0) { return undefined; } for (const tier of allowedTiers) { if (tier?.isDefault) { return tier.id; } } return allowedTiers[0]?.id; } /** * Promise-based delay utility. */ function wait(ms: number): Promise { return new Promise(function (resolve) { setTimeout(resolve, ms); }); } /** * Extracts the cloudaicompanion project id from loadCodeAssist responses. */ function extractManagedProjectId(payload: LoadCodeAssistPayload | null): string | undefined { if (!payload) { return undefined; } if (typeof payload.cloudaicompanionProject === "string") { return payload.cloudaicompanionProject; } if (payload.cloudaicompanionProject && typeof payload.cloudaicompanionProject.id === "string") { return payload.cloudaicompanionProject.id; } return undefined; } /** * Generates a cache key for project context based on refresh token. */ function getCacheKey(auth: OAuthAuthDetails): string | undefined { const refresh = auth.refresh?.trim(); return refresh ? refresh : undefined; } /** * Clears cached project context results and pending promises, globally or for a refresh key. */ export function invalidateProjectContextCache(refresh?: string): void { if (!refresh) { projectContextPendingCache.clear(); projectContextResultCache.clear(); return; } projectContextPendingCache.delete(refresh); projectContextResultCache.delete(refresh); } /** * Loads managed project information for the given access token and optional project. */ export async function loadManagedProject( accessToken: string, projectId?: string, ): Promise { const metadata = buildMetadata(projectId); const requestBody: Record = { metadata }; const loadHeaders: Record = { "Content-Type": "application/json", Authorization: `Bearer ${accessToken}`, "User-Agent": "google-api-nodejs-client/9.15.1", "X-Goog-Api-Client": "google-cloud-sdk vscode_cloudshelleditor/0.1", "Client-Metadata": getAntigravityHeaders()["Client-Metadata"], }; const loadEndpoints = Array.from( new Set([...ANTIGRAVITY_LOAD_ENDPOINTS, ...ANTIGRAVITY_ENDPOINT_FALLBACKS]), ); for (const baseEndpoint of loadEndpoints) { try { const response = await fetch( `${baseEndpoint}/v1internal:loadCodeAssist`, { method: "POST", headers: loadHeaders, body: JSON.stringify(requestBody), }, ); if (!response.ok) { continue; } return (await response.json()) as LoadCodeAssistPayload; } catch (error) { log.debug("Failed to load managed project", { endpoint: baseEndpoint, error: String(error) }); continue; } } return null; } /** * Onboards a managed project for the user, optionally retrying until completion. */ export async function onboardManagedProject( accessToken: string, tierId: string, projectId?: string, attempts = 10, delayMs = 5000, ): Promise { const metadata = buildMetadata(projectId); const requestBody: Record = { tierId, metadata, }; for (const baseEndpoint of ANTIGRAVITY_ENDPOINT_FALLBACKS) { for (let attempt = 0; attempt < attempts; attempt += 1) { try { const response = await fetch( `${baseEndpoint}/v1internal:onboardUser`, { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${accessToken}`, ...getAntigravityHeaders(), }, body: JSON.stringify(requestBody), }, ); if (!response.ok) { break; } const payload = (await response.json()) as OnboardUserPayload; const managedProjectId = payload.response?.cloudaicompanionProject?.id; if (payload.done && managedProjectId) { return managedProjectId; } if (payload.done && projectId) { return projectId; } } catch (error) { log.debug("Failed to onboard managed project", { endpoint: baseEndpoint, error: String(error) }); break; } await wait(delayMs); } } return undefined; } /** * Resolves an effective project ID for the current auth state, caching results per refresh token. */ export async function ensureProjectContext(auth: OAuthAuthDetails): Promise { const accessToken = auth.access; if (!accessToken) { return { auth, effectiveProjectId: "" }; } const cacheKey = getCacheKey(auth); if (cacheKey) { const cached = projectContextResultCache.get(cacheKey); if (cached) { return cached; } const pending = projectContextPendingCache.get(cacheKey); if (pending) { return pending; } } const resolveContext = async (): Promise => { const parts = parseRefreshParts(auth.refresh); if (parts.managedProjectId) { return { auth, effectiveProjectId: parts.managedProjectId }; } const fallbackProjectId = ANTIGRAVITY_DEFAULT_PROJECT_ID; const persistManagedProject = async (managedProjectId: string): Promise => { const updatedAuth: OAuthAuthDetails = { ...auth, refresh: formatRefreshParts({ refreshToken: parts.refreshToken, projectId: parts.projectId, managedProjectId, }), }; return { auth: updatedAuth, effectiveProjectId: managedProjectId }; }; // Try to resolve a managed project from Antigravity if possible. const loadPayload = await loadManagedProject(accessToken, parts.projectId ?? fallbackProjectId); const resolvedManagedProjectId = extractManagedProjectId(loadPayload); if (resolvedManagedProjectId) { return persistManagedProject(resolvedManagedProjectId); } // No managed project found - try to auto-provision one via onboarding. // This handles accounts that were added before managed project provisioning was required. const tierId = getDefaultTierId(loadPayload?.allowedTiers) ?? "FREE"; log.debug("Auto-provisioning managed project", { tierId, projectId: parts.projectId }); const provisionedProjectId = await onboardManagedProject( accessToken, tierId, parts.projectId, ); if (provisionedProjectId) { log.debug("Successfully provisioned managed project", { provisionedProjectId }); return persistManagedProject(provisionedProjectId); } log.warn("Failed to provision managed project - account may not work correctly", { hasProjectId: !!parts.projectId, }); if (parts.projectId) { return { auth, effectiveProjectId: parts.projectId }; } // No project id present in auth; fall back to the hardcoded id for requests. return { auth, effectiveProjectId: fallbackProjectId }; }; if (!cacheKey) { return resolveContext(); } const promise = resolveContext() .then((result) => { const nextKey = getCacheKey(result.auth) ?? cacheKey; projectContextPendingCache.delete(cacheKey); projectContextResultCache.set(nextKey, result); if (nextKey !== cacheKey) { projectContextResultCache.delete(cacheKey); } return result; }) .catch((error) => { projectContextPendingCache.delete(cacheKey); throw error; }); projectContextPendingCache.set(cacheKey, promise); return promise; } ================================================ FILE: src/plugin/quota-fallback.test.ts ================================================ import { beforeAll, describe, expect, it, vi } from "vitest"; import type { HeaderStyle, ModelFamily } from "./accounts"; type ResolveQuotaFallbackHeaderStyle = (input: { family: ModelFamily; headerStyle: HeaderStyle; alternateStyle: HeaderStyle | null; }) => HeaderStyle | null; type GetHeaderStyleFromUrl = ( urlString: string, family: ModelFamily, cliFirst?: boolean, ) => HeaderStyle; type ResolveHeaderRoutingDecision = ( urlString: string, family: ModelFamily, config: unknown, ) => { cliFirst: boolean; preferredHeaderStyle: HeaderStyle; explicitQuota: boolean; allowQuotaFallback: boolean; }; let resolveQuotaFallbackHeaderStyle: ResolveQuotaFallbackHeaderStyle | undefined; let getHeaderStyleFromUrl: GetHeaderStyleFromUrl | undefined; let resolveHeaderRoutingDecision: ResolveHeaderRoutingDecision | undefined; beforeAll(async () => { vi.mock("@opencode-ai/plugin", () => ({ tool: vi.fn(), })); const { __testExports } = await import("../plugin"); resolveQuotaFallbackHeaderStyle = (__testExports as { resolveQuotaFallbackHeaderStyle?: ResolveQuotaFallbackHeaderStyle; }).resolveQuotaFallbackHeaderStyle; getHeaderStyleFromUrl = (__testExports as { getHeaderStyleFromUrl?: GetHeaderStyleFromUrl; }).getHeaderStyleFromUrl; resolveHeaderRoutingDecision = (__testExports as { resolveHeaderRoutingDecision?: ResolveHeaderRoutingDecision; }).resolveHeaderRoutingDecision; }); describe("quota fallback direction", () => { it("falls back from gemini-cli to antigravity when alternate quota is available", () => { const result = resolveQuotaFallbackHeaderStyle?.({ family: "gemini", headerStyle: "gemini-cli", alternateStyle: "antigravity", }); expect(result).toBe("antigravity"); }); it("falls back from antigravity to gemini-cli when alternate quota is available", () => { const result = resolveQuotaFallbackHeaderStyle?.({ family: "gemini", headerStyle: "antigravity", alternateStyle: "gemini-cli", }); expect(result).toBe("gemini-cli"); }); it("returns null when no alternate quota is available", () => { const result = resolveQuotaFallbackHeaderStyle?.({ family: "gemini", headerStyle: "antigravity", alternateStyle: null, }); expect(result).toBeNull(); }); }); describe("header style resolution", () => { it("uses gemini-cli for unsuffixed Gemini models when cli_first is enabled", () => { const headerStyle = getHeaderStyleFromUrl?.( "https://generativelanguage.googleapis.com/v1beta/models/gemini-3-flash:streamGenerateContent", "gemini", true, ); expect(headerStyle).toBe("gemini-cli"); }); it("keeps antigravity for unsuffixed Gemini models when cli_first is disabled", () => { const headerStyle = getHeaderStyleFromUrl?.( "https://generativelanguage.googleapis.com/v1beta/models/gemini-3-flash:streamGenerateContent", "gemini", false, ); expect(headerStyle).toBe("antigravity"); }); it("keeps antigravity for explicit antigravity prefix when cli_first is enabled", () => { const headerStyle = getHeaderStyleFromUrl?.( "https://generativelanguage.googleapis.com/v1beta/models/antigravity-gemini-3-flash:streamGenerateContent", "gemini", true, ); expect(headerStyle).toBe("antigravity"); }); it("keeps antigravity for Claude when cli_first is enabled", () => { const headerStyle = getHeaderStyleFromUrl?.( "https://generativelanguage.googleapis.com/v1beta/models/claude-opus-4-6-thinking:streamGenerateContent", "claude", true, ); expect(headerStyle).toBe("antigravity"); }); }); describe("header routing decision", () => { it("defaults to antigravity-first for unsuffixed Gemini when cli_first is disabled", () => { const decision = resolveHeaderRoutingDecision?.( "https://generativelanguage.googleapis.com/v1beta/models/gemini-3-flash:streamGenerateContent", "gemini", { cli_first: false, }, ); expect(decision).toMatchObject({ cliFirst: false, preferredHeaderStyle: "antigravity", explicitQuota: false, allowQuotaFallback: true, }); }); it("uses gemini-cli-first for unsuffixed Gemini when cli_first is enabled", () => { const decision = resolveHeaderRoutingDecision?.( "https://generativelanguage.googleapis.com/v1beta/models/gemini-3-flash:streamGenerateContent", "gemini", { cli_first: true, }, ); expect(decision).toMatchObject({ cliFirst: true, preferredHeaderStyle: "gemini-cli", explicitQuota: false, allowQuotaFallback: true, }); }); it("keeps explicit antigravity prefix as primary route while fallback remains available", () => { const decision = resolveHeaderRoutingDecision?.( "https://generativelanguage.googleapis.com/v1beta/models/antigravity-gemini-3-flash:streamGenerateContent", "gemini", { cli_first: true, }, ); expect(decision).toMatchObject({ cliFirst: true, preferredHeaderStyle: "antigravity", explicitQuota: true, allowQuotaFallback: true, }); }); it("ignores legacy quota_fallback when deciding Gemini fallback availability", () => { const decision = resolveHeaderRoutingDecision?.( "https://generativelanguage.googleapis.com/v1beta/models/gemini-3-flash:streamGenerateContent", "gemini", { cli_first: false, quota_fallback: false, }, ); expect(decision).toMatchObject({ cliFirst: false, preferredHeaderStyle: "antigravity", explicitQuota: false, allowQuotaFallback: true, }); }); }); ================================================ FILE: src/plugin/quota.ts ================================================ import { ANTIGRAVITY_ENDPOINT_PROD, getAntigravityHeaders, ANTIGRAVITY_PROVIDER_ID, } from "../constants"; import { accessTokenExpired, formatRefreshParts, parseRefreshParts } from "./auth"; import { logQuotaFetch, logQuotaStatus } from "./debug"; import { ensureProjectContext } from "./project"; import { refreshAccessToken } from "./token"; import { getModelFamily } from "./transform/model-resolver"; import type { PluginClient, OAuthAuthDetails } from "./types"; import type { AccountMetadataV3 } from "./storage"; const FETCH_TIMEOUT_MS = 10000; export type QuotaGroup = "claude" | "gemini-pro" | "gemini-flash"; export interface QuotaGroupSummary { remainingFraction?: number; resetTime?: string; modelCount: number; } export interface QuotaSummary { groups: Partial>; modelCount: number; error?: string; } // Gemini CLI quota types export interface GeminiCliQuotaModel { modelId: string; remainingFraction: number; resetTime?: string; } export interface GeminiCliQuotaSummary { models: GeminiCliQuotaModel[]; error?: string; } interface RetrieveUserQuotaResponse { buckets?: { remainingAmount?: string; remainingFraction?: number; resetTime?: string; tokenType?: string; modelId?: string; }[]; } export type AccountQuotaStatus = "ok" | "disabled" | "error"; export interface AccountQuotaResult { index: number; email?: string; status: AccountQuotaStatus; error?: string; disabled?: boolean; quota?: QuotaSummary; geminiCliQuota?: GeminiCliQuotaSummary; updatedAccount?: AccountMetadataV3; } interface FetchAvailableModelsResponse { models?: Record; } interface FetchAvailableModelEntry { quotaInfo?: { remainingFraction?: number; resetTime?: string; }; displayName?: string; modelName?: string; } function buildAuthFromAccount(account: AccountMetadataV3): OAuthAuthDetails { return { type: "oauth", refresh: formatRefreshParts({ refreshToken: account.refreshToken, projectId: account.projectId, managedProjectId: account.managedProjectId, }), access: undefined, expires: undefined, }; } function normalizeRemainingFraction(value: unknown): number { // If value is missing or invalid, treat as exhausted (0%) if (typeof value !== "number" || !Number.isFinite(value)) { return 0; } if (value < 0) return 0; if (value > 1) return 1; return value; } function parseResetTime(resetTime?: string): number | null { if (!resetTime) return null; const timestamp = Date.parse(resetTime); if (!Number.isFinite(timestamp)) { return null; } return timestamp; } function classifyQuotaGroup(modelName: string, displayName?: string): QuotaGroup | null { const combined = `${modelName} ${displayName ?? ""}`.toLowerCase(); if (combined.includes("claude")) { return "claude"; } const isGemini3 = combined.includes("gemini-3") || combined.includes("gemini 3"); if (!isGemini3) { return null; } const family = getModelFamily(modelName); return family === "gemini-flash" ? "gemini-flash" : "gemini-pro"; } function aggregateQuota(models?: Record): QuotaSummary { const groups: Partial> = {}; if (!models) { return { groups, modelCount: 0 }; } let totalCount = 0; for (const [modelName, entry] of Object.entries(models)) { const group = classifyQuotaGroup(modelName, entry.displayName ?? entry.modelName); if (!group) { continue; } const quotaInfo = entry.quotaInfo; const remainingFraction = quotaInfo ? normalizeRemainingFraction(quotaInfo.remainingFraction) : undefined; const resetTime = quotaInfo?.resetTime; const resetTimestamp = parseResetTime(resetTime); totalCount += 1; const existing = groups[group]; const nextCount = (existing?.modelCount ?? 0) + 1; const nextRemaining = remainingFraction === undefined ? existing?.remainingFraction : existing?.remainingFraction === undefined ? remainingFraction : Math.min(existing.remainingFraction, remainingFraction); let nextResetTime = existing?.resetTime; if (resetTimestamp !== null) { if (!existing?.resetTime) { nextResetTime = resetTime; } else { const existingTimestamp = parseResetTime(existing.resetTime); if (existingTimestamp === null || resetTimestamp < existingTimestamp) { nextResetTime = resetTime; } } } groups[group] = { remainingFraction: nextRemaining, resetTime: nextResetTime, modelCount: nextCount, }; } return { groups, modelCount: totalCount }; } async function fetchWithTimeout(url: string, options: RequestInit, timeoutMs = FETCH_TIMEOUT_MS): Promise { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), timeoutMs); try { return await fetch(url, { ...options, signal: controller.signal }); } finally { clearTimeout(timeout); } } async function fetchAvailableModels( accessToken: string, projectId: string, ): Promise { const endpoint = ANTIGRAVITY_ENDPOINT_PROD; const quotaUserAgent = getAntigravityHeaders()["User-Agent"] || "antigravity/windows/amd64"; const errors: string[] = []; const body = projectId ? { project: projectId } : {}; const response = await fetchWithTimeout(`${endpoint}/v1internal:fetchAvailableModels`, { method: "POST", headers: { Authorization: `Bearer ${accessToken}`, "Content-Type": "application/json", "User-Agent": quotaUserAgent, }, body: JSON.stringify(body), }); if (response.ok) { return (await response.json()) as FetchAvailableModelsResponse; } const message = await response.text().catch(() => ""); const snippet = message.trim().slice(0, 200); errors.push( `fetchAvailableModels ${response.status} at ${endpoint}${snippet ? `: ${snippet}` : ""}`, ); throw new Error(errors.join("; ") || "fetchAvailableModels failed"); } async function fetchGeminiCliQuota( accessToken: string, projectId: string, ): Promise { const endpoint = ANTIGRAVITY_ENDPOINT_PROD; // Use Gemini CLI user-agent to get CLI quota buckets (not Antigravity buckets) const platform = process.platform || "darwin"; const arch = process.arch || "arm64"; const geminiCliUserAgent = `GeminiCLI/1.0.0/gemini-2.5-pro (${platform}; ${arch})`; const body = projectId ? { project: projectId } : {}; try { const response = await fetchWithTimeout(`${endpoint}/v1internal:retrieveUserQuota`, { method: "POST", headers: { Authorization: `Bearer ${accessToken}`, "Content-Type": "application/json", "User-Agent": geminiCliUserAgent, }, body: JSON.stringify(body), }); if (response.ok) { const data = (await response.json()) as RetrieveUserQuotaResponse; return data; } // Non-OK response - return empty buckets return { buckets: [] }; } catch { // Network error or timeout - return empty buckets return { buckets: [] }; } } function aggregateGeminiCliQuota(response: RetrieveUserQuotaResponse): GeminiCliQuotaSummary { const models: GeminiCliQuotaModel[] = []; if (!response.buckets || response.buckets.length === 0) { return { models }; } for (const bucket of response.buckets) { if (!bucket.modelId) { continue; } // Filter out models we don't care about for Gemini CLI quotas // Only show gemini-3-* and gemini-2.5-pro models (the premium ones) const modelId = bucket.modelId; const isRelevantModel = modelId.startsWith("gemini-3-") || modelId === "gemini-2.5-pro"; if (!isRelevantModel) { continue; } models.push({ modelId: bucket.modelId, remainingFraction: normalizeRemainingFraction(bucket.remainingFraction), resetTime: bucket.resetTime, }); } // Sort by model ID for consistent display models.sort((a, b) => a.modelId.localeCompare(b.modelId)); return { models }; } function applyAccountUpdates(account: AccountMetadataV3, auth: OAuthAuthDetails): AccountMetadataV3 | undefined { const parts = parseRefreshParts(auth.refresh); if (!parts.refreshToken) { return undefined; } const updated: AccountMetadataV3 = { ...account, refreshToken: parts.refreshToken, projectId: parts.projectId ?? account.projectId, managedProjectId: parts.managedProjectId ?? account.managedProjectId, }; const changed = updated.refreshToken !== account.refreshToken || updated.projectId !== account.projectId || updated.managedProjectId !== account.managedProjectId; return changed ? updated : undefined; } export async function checkAccountsQuota( accounts: AccountMetadataV3[], client: PluginClient, providerId = ANTIGRAVITY_PROVIDER_ID, ): Promise { const results: AccountQuotaResult[] = []; logQuotaFetch("start", accounts.length); for (const [index, account] of accounts.entries()) { const disabled = account.enabled === false; let auth = buildAuthFromAccount(account); try { if (accessTokenExpired(auth)) { const refreshed = await refreshAccessToken(auth, client, providerId); if (!refreshed) { throw new Error("Token refresh failed"); } auth = refreshed; } const projectContext = await ensureProjectContext(auth); auth = projectContext.auth; const updatedAccount = applyAccountUpdates(account, auth); let quotaResult: QuotaSummary; let geminiCliQuotaResult: GeminiCliQuotaSummary; // Fetch both Antigravity and Gemini CLI quotas in parallel const [antigravityResponse, geminiCliResponse] = await Promise.all([ fetchAvailableModels(auth.access ?? "", projectContext.effectiveProjectId) .catch((error): FetchAvailableModelsResponse => ({ models: undefined })), fetchGeminiCliQuota(auth.access ?? "", projectContext.effectiveProjectId), ]); // Process Antigravity quota if (antigravityResponse.models === undefined) { quotaResult = { groups: {}, modelCount: 0, error: "Failed to fetch Antigravity quota", }; } else { quotaResult = aggregateQuota(antigravityResponse.models); } // Process Gemini CLI quota geminiCliQuotaResult = aggregateGeminiCliQuota(geminiCliResponse); if (geminiCliResponse.buckets === undefined || geminiCliResponse.buckets.length === 0) { geminiCliQuotaResult.error = geminiCliQuotaResult.models.length === 0 ? "No Gemini CLI quota available" : undefined; } results.push({ index, email: account.email, status: "ok", disabled, quota: quotaResult, geminiCliQuota: geminiCliQuotaResult, updatedAccount, }); // Log quota status for each family for (const [family, groupQuota] of Object.entries(quotaResult.groups)) { const remainingPercent = (groupQuota.remainingFraction ?? 0) * 100; logQuotaStatus(account.email, index, remainingPercent, family); } } catch (error) { results.push({ index, email: account.email, status: "error", disabled, error: error instanceof Error ? error.message : String(error), }); logQuotaFetch("error", undefined, `account=${account.email ?? index} error=${error instanceof Error ? error.message : String(error)}`); } } logQuotaFetch("complete", accounts.length, `ok=${results.filter(r => r.status === "ok").length} errors=${results.filter(r => r.status === "error").length}`); return results; } ================================================ FILE: src/plugin/recovery/constants.ts ================================================ /** * Constants for session recovery storage paths. * * Based on oh-my-opencode/src/hooks/session-recovery/constants.ts */ import { join } from "node:path"; import { homedir } from "node:os"; /** * Get the XDG data directory for OpenCode storage. * Falls back to ~/.local/share on Linux/Mac, or APPDATA on Windows. */ function getXdgData(): string { const platform = process.platform; if (platform === "win32") { return process.env.APPDATA || join(homedir(), "AppData", "Roaming"); } return process.env.XDG_DATA_HOME || join(homedir(), ".local", "share"); } /** * Get the XDG config directory for Antigravity config. * Falls back to ~/.config on Linux/Mac, or APPDATA on Windows. */ export function getXdgConfig(): string { const platform = process.platform; if (platform === "win32") { return process.env.APPDATA || join(homedir(), "AppData", "Roaming"); } return process.env.XDG_CONFIG_HOME || join(homedir(), ".config"); } /** * Get the Antigravity config directory. * Default: ~/.config/opencode/antigravity.json */ export function getAntigravityConfigDir(): string { return join(getXdgConfig(), "opencode"); } export const OPENCODE_STORAGE = join(getXdgData(), "opencode", "storage"); export const MESSAGE_STORAGE = join(OPENCODE_STORAGE, "message"); export const PART_STORAGE = join(OPENCODE_STORAGE, "part"); export const THINKING_TYPES = new Set(["thinking", "redacted_thinking", "reasoning"]); export const META_TYPES = new Set(["step-start", "step-finish"]); export const CONTENT_TYPES = new Set(["text", "tool", "tool_use", "tool_result"]); ================================================ FILE: src/plugin/recovery/index.ts ================================================ /** * Session recovery module for opencode-antigravity-auth. * * Provides recovery from: * - tool_result_missing: Interrupted tool executions * - thinking_block_order: Corrupted thinking blocks * - thinking_disabled_violation: Thinking in non-thinking model */ export * from "./types"; export * from "./constants"; export * from "./storage"; ================================================ FILE: src/plugin/recovery/storage.ts ================================================ /** * Storage utilities for reading OpenCode's session data. * * Based on oh-my-opencode/src/hooks/session-recovery/storage.ts */ import { existsSync, mkdirSync, readdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { MESSAGE_STORAGE, PART_STORAGE, THINKING_TYPES, META_TYPES } from "./constants"; import type { StoredMessageMeta, StoredPart, StoredTextPart } from "./types"; // ============================================================================= // ID Generation // ============================================================================= export function generatePartId(): string { const timestamp = Date.now().toString(16); const random = Math.random().toString(36).substring(2, 10); return `prt_${timestamp}${random}`; } // ============================================================================= // Directory Helpers // ============================================================================= export function getMessageDir(sessionID: string): string { if (!existsSync(MESSAGE_STORAGE)) return ""; const directPath = join(MESSAGE_STORAGE, sessionID); if (existsSync(directPath)) { return directPath; } // Search in subdirectories try { for (const dir of readdirSync(MESSAGE_STORAGE)) { const sessionPath = join(MESSAGE_STORAGE, dir, sessionID); if (existsSync(sessionPath)) { return sessionPath; } } } catch { // Ignore read errors } return ""; } // ============================================================================= // Message Reading // ============================================================================= export function readMessages(sessionID: string): StoredMessageMeta[] { const messageDir = getMessageDir(sessionID); if (!messageDir || !existsSync(messageDir)) return []; const messages: StoredMessageMeta[] = []; try { for (const file of readdirSync(messageDir)) { if (!file.endsWith(".json")) continue; try { const content = readFileSync(join(messageDir, file), "utf-8"); messages.push(JSON.parse(content)); } catch { continue; } } } catch { return []; } return messages.sort((a, b) => { const aTime = a.time?.created ?? 0; const bTime = b.time?.created ?? 0; if (aTime !== bTime) return aTime - bTime; return a.id.localeCompare(b.id); }); } // ============================================================================= // Part Reading // ============================================================================= export function readParts(messageID: string): StoredPart[] { const partDir = join(PART_STORAGE, messageID); if (!existsSync(partDir)) return []; const parts: StoredPart[] = []; try { for (const file of readdirSync(partDir)) { if (!file.endsWith(".json")) continue; try { const content = readFileSync(join(partDir, file), "utf-8"); parts.push(JSON.parse(content)); } catch { continue; } } } catch { return []; } return parts; } // ============================================================================= // Content Helpers // ============================================================================= export function hasContent(part: StoredPart): boolean { if (THINKING_TYPES.has(part.type)) return false; if (META_TYPES.has(part.type)) return false; if (part.type === "text") { const textPart = part as StoredTextPart; return !!(textPart.text?.trim()); } if (part.type === "tool" || part.type === "tool_use") { return true; } if (part.type === "tool_result") { return true; } return false; } export function messageHasContent(messageID: string): boolean { const parts = readParts(messageID); return parts.some(hasContent); } // ============================================================================= // Part Injection (for recovery) // ============================================================================= export function injectTextPart(sessionID: string, messageID: string, text: string): boolean { const partDir = join(PART_STORAGE, messageID); try { if (!existsSync(partDir)) { mkdirSync(partDir, { recursive: true }); } const partId = generatePartId(); const part: StoredTextPart = { id: partId, sessionID, messageID, type: "text", text, synthetic: true, }; writeFileSync(join(partDir, `${partId}.json`), JSON.stringify(part, null, 2)); return true; } catch { return false; } } // ============================================================================= // Thinking Block Recovery // ============================================================================= export function findMessagesWithThinkingBlocks(sessionID: string): string[] { const messages = readMessages(sessionID); const result: string[] = []; for (const msg of messages) { if (msg.role !== "assistant") continue; const parts = readParts(msg.id); const hasThinking = parts.some((p) => THINKING_TYPES.has(p.type)); if (hasThinking) { result.push(msg.id); } } return result; } export function findMessagesWithThinkingOnly(sessionID: string): string[] { const messages = readMessages(sessionID); const result: string[] = []; for (const msg of messages) { if (msg.role !== "assistant") continue; const parts = readParts(msg.id); if (parts.length === 0) continue; const hasThinking = parts.some((p) => THINKING_TYPES.has(p.type)); const hasTextContent = parts.some(hasContent); // Has thinking but no text content = orphan thinking if (hasThinking && !hasTextContent) { result.push(msg.id); } } return result; } export function findMessagesWithOrphanThinking(sessionID: string): string[] { const messages = readMessages(sessionID); const result: string[] = []; for (let i = 0; i < messages.length; i++) { const msg = messages[i]; if (!msg || msg.role !== "assistant") continue; const parts = readParts(msg.id); if (parts.length === 0) continue; const sortedParts = [...parts].sort((a, b) => a.id.localeCompare(b.id)); const firstPart = sortedParts[0]; if (!firstPart) continue; const firstIsThinking = THINKING_TYPES.has(firstPart.type); // If first part is not thinking, it's orphan if (!firstIsThinking) { result.push(msg.id); } } return result; } export function prependThinkingPart(sessionID: string, messageID: string): boolean { const partDir = join(PART_STORAGE, messageID); try { if (!existsSync(partDir)) { mkdirSync(partDir, { recursive: true }); } const partId = "prt_0000000000_thinking"; const part = { id: partId, sessionID, messageID, type: "thinking", thinking: "", synthetic: true, }; writeFileSync(join(partDir, `${partId}.json`), JSON.stringify(part, null, 2)); return true; } catch { return false; } } export function stripThinkingParts(messageID: string): boolean { const partDir = join(PART_STORAGE, messageID); if (!existsSync(partDir)) return false; let anyRemoved = false; try { for (const file of readdirSync(partDir)) { if (!file.endsWith(".json")) continue; try { const filePath = join(partDir, file); const content = readFileSync(filePath, "utf-8"); const part = JSON.parse(content) as StoredPart; if (THINKING_TYPES.has(part.type)) { unlinkSync(filePath); anyRemoved = true; } } catch { continue; } } } catch { return false; } return anyRemoved; } // ============================================================================= // Empty Message Recovery // ============================================================================= export function findEmptyMessages(sessionID: string): string[] { const messages = readMessages(sessionID); const emptyIds: string[] = []; for (const msg of messages) { if (!messageHasContent(msg.id)) { emptyIds.push(msg.id); } } return emptyIds; } export function findEmptyMessageByIndex(sessionID: string, targetIndex: number): string | null { const messages = readMessages(sessionID); // API index may differ from storage index due to system messages const indicesToTry = [targetIndex, targetIndex - 1, targetIndex - 2]; for (const idx of indicesToTry) { if (idx < 0 || idx >= messages.length) continue; const targetMsg = messages[idx]; if (!targetMsg) continue; if (!messageHasContent(targetMsg.id)) { return targetMsg.id; } } return null; } export function findMessageByIndexNeedingThinking(sessionID: string, targetIndex: number): string | null { const messages = readMessages(sessionID); if (targetIndex < 0 || targetIndex >= messages.length) return null; const targetMsg = messages[targetIndex]; if (!targetMsg || targetMsg.role !== "assistant") return null; const parts = readParts(targetMsg.id); if (parts.length === 0) return null; const sortedParts = [...parts].sort((a, b) => a.id.localeCompare(b.id)); const firstPart = sortedParts[0]; if (!firstPart) return null; const firstIsThinking = THINKING_TYPES.has(firstPart.type); if (!firstIsThinking) { return targetMsg.id; } return null; } export function replaceEmptyTextParts(messageID: string, replacementText: string): boolean { const partDir = join(PART_STORAGE, messageID); if (!existsSync(partDir)) return false; let anyReplaced = false; try { for (const file of readdirSync(partDir)) { if (!file.endsWith(".json")) continue; try { const filePath = join(partDir, file); const content = readFileSync(filePath, "utf-8"); const part = JSON.parse(content) as StoredPart; if (part.type === "text") { const textPart = part as StoredTextPart; if (!textPart.text?.trim()) { textPart.text = replacementText; textPart.synthetic = true; writeFileSync(filePath, JSON.stringify(textPart, null, 2)); anyReplaced = true; } } } catch { continue; } } } catch { return false; } return anyReplaced; } export function findMessagesWithEmptyTextParts(sessionID: string): string[] { const messages = readMessages(sessionID); const result: string[] = []; for (const msg of messages) { const parts = readParts(msg.id); const hasEmptyTextPart = parts.some((p) => { if (p.type !== "text") return false; const textPart = p as StoredTextPart; return !textPart.text?.trim(); }); if (hasEmptyTextPart) { result.push(msg.id); } } return result; } ================================================ FILE: src/plugin/recovery/types.ts ================================================ /** * Types for session recovery. * * Based on oh-my-opencode/src/hooks/session-recovery/types.ts */ // ============================================================================= // Storage Types (for reading from OpenCode's filesystem) // ============================================================================= export type ThinkingPartType = "thinking" | "redacted_thinking" | "reasoning"; export type MetaPartType = "step-start" | "step-finish"; export type ContentPartType = "text" | "tool" | "tool_use" | "tool_result"; export interface StoredMessageMeta { id: string; sessionID: string; role: "user" | "assistant"; parentID?: string; time?: { created: number; completed?: number; }; error?: unknown; } export interface StoredTextPart { id: string; sessionID: string; messageID: string; type: "text"; text: string; synthetic?: boolean; ignored?: boolean; } export interface StoredToolPart { id: string; sessionID: string; messageID: string; type: "tool"; callID: string; tool: string; state: { status: "pending" | "running" | "completed" | "error"; input: Record; output?: string; error?: string; }; } export interface StoredReasoningPart { id: string; sessionID: string; messageID: string; type: "reasoning"; text: string; } export interface StoredStepPart { id: string; sessionID: string; messageID: string; type: "step-start" | "step-finish"; } export type StoredPart = | StoredTextPart | StoredToolPart | StoredReasoningPart | StoredStepPart | { id: string; sessionID: string; messageID: string; type: string; [key: string]: unknown; }; // ============================================================================= // API Types (for working with OpenCode SDK responses) // ============================================================================= export interface MessagePart { type: string; id?: string; text?: string; thinking?: string; name?: string; input?: Record; callID?: string; } export interface MessageData { info?: { id?: string; role?: string; sessionID?: string; parentID?: string; error?: unknown; agent?: string; model?: { providerID: string; modelID: string; }; system?: string; tools?: Record; }; parts?: MessagePart[]; } export interface MessageInfo { id?: string; role?: string; sessionID?: string; parentID?: string; error?: unknown; } export interface ResumeConfig { sessionID: string; agent?: string; model?: { providerID: string; modelID: string; }; } // ============================================================================= // Hook Types // ============================================================================= export type RecoveryErrorType = | "tool_result_missing" | "thinking_block_order" | "thinking_disabled_violation" | null; export interface ToolUsePart { type: "tool_use"; id: string; name: string; input: Record; } export interface ToolResultPart { type: "tool_result"; tool_use_id: string; content: string; } ================================================ FILE: src/plugin/recovery.test.ts ================================================ import { describe, it, expect } from "vitest"; import { detectErrorType, isRecoverableError } from "./recovery"; describe("detectErrorType", () => { describe("tool_result_missing detection", () => { it("detects tool_use without tool_result error", () => { const error = { type: "invalid_request_error", message: "messages.105: `tool_use` ids were found without `tool_result` blocks immediately after: tool-call-59" }; expect(detectErrorType(error)).toBe("tool_result_missing"); }); it("detects tool_use/tool_result mismatch error", () => { const error = "Each `tool_use` block must have a corresponding `tool_result` block in the next message."; expect(detectErrorType(error)).toBe("tool_result_missing"); }); it("detects error from string message", () => { const error = "tool_use without matching tool_result"; expect(detectErrorType(error)).toBe("tool_result_missing"); }); }); describe("thinking_block_order detection", () => { it("detects thinking first block error", () => { const error = "thinking must be the first block in the message"; expect(detectErrorType(error)).toBe("thinking_block_order"); }); it("detects thinking must start with error", () => { const error = "Response must start with thinking block"; expect(detectErrorType(error)).toBe("thinking_block_order"); }); it("detects thinking preceeding error", () => { const error = "thinking block preceeding tool use is required"; expect(detectErrorType(error)).toBe("thinking_block_order"); }); it("detects thinking expected/found error", () => { const error = "Expected thinking block but found text"; expect(detectErrorType(error)).toBe("thinking_block_order"); }); }); describe("thinking_disabled_violation detection", () => { it("detects thinking disabled error", () => { const error = "thinking is disabled for this model and cannot contain thinking blocks"; expect(detectErrorType(error)).toBe("thinking_disabled_violation"); }); }); describe("non-recoverable errors", () => { it("returns null for prompt too long error", () => { // This is handled separately, not as a recoverable error const error = { message: "Prompt is too long" }; expect(detectErrorType(error)).toBeNull(); }); it("returns null for context length exceeded error", () => { const error = "context length exceeded"; expect(detectErrorType(error)).toBeNull(); }); it("returns null for generic errors", () => { expect(detectErrorType("Something went wrong")).toBeNull(); expect(detectErrorType({ message: "Unknown error" })).toBeNull(); expect(detectErrorType(null)).toBeNull(); expect(detectErrorType(undefined)).toBeNull(); }); it("returns null for rate limit errors", () => { const error = { message: "Rate limit exceeded. Retry after 5s" }; expect(detectErrorType(error)).toBeNull(); }); it("returns null for generic INVALID_ARGUMENT with debug expected/found metadata", () => { const error = { message: "Request contains an invalid argument. [Debug Info] Requested Model: antigravity-claude-opus-4-6-thinking Tool Debug Summary: expected=1 found=0", }; expect(detectErrorType(error)).toBeNull(); }); }); }); describe("isRecoverableError", () => { it("returns true for tool_result_missing", () => { const error = "tool_use without tool_result"; expect(isRecoverableError(error)).toBe(true); }); it("returns true for thinking_block_order", () => { const error = "thinking must be the first block"; expect(isRecoverableError(error)).toBe(true); }); it("returns true for thinking_disabled_violation", () => { const error = "thinking is disabled and cannot contain thinking"; expect(isRecoverableError(error)).toBe(true); }); it("returns false for non-recoverable errors", () => { expect(isRecoverableError("Prompt is too long")).toBe(false); expect(isRecoverableError("context length exceeded")).toBe(false); expect(isRecoverableError("Generic error")).toBe(false); expect(isRecoverableError(null)).toBe(false); }); }); // ============================================================================= // CONTEXT ERROR MESSAGES // These test that error messages from the API can be properly categorized // ============================================================================= describe("context error message patterns", () => { describe("prompt too long patterns", () => { const promptTooLongPatterns = [ "Prompt is too long", "prompt is too long for this model", "The prompt is too long", ]; it.each(promptTooLongPatterns)("'%s' is not a recoverable error", (msg) => { expect(isRecoverableError(msg)).toBe(false); expect(detectErrorType(msg)).toBeNull(); }); }); describe("context length exceeded patterns", () => { const contextLengthPatterns = [ "context length exceeded", "context_length_exceeded", "maximum context length", "exceeds the maximum context window", ]; it.each(contextLengthPatterns)("'%s' is not a recoverable error", (msg) => { expect(isRecoverableError(msg)).toBe(false); expect(detectErrorType(msg)).toBeNull(); }); }); describe("tool pairing error patterns", () => { const toolPairingPatterns = [ "tool_use ids were found without tool_result blocks immediately after", "Each tool_use block must have a corresponding tool_result", "tool_use without matching tool_result", ]; it.each(toolPairingPatterns)("'%s' is detected as tool_result_missing", (msg) => { expect(detectErrorType(msg)).toBe("tool_result_missing"); expect(isRecoverableError(msg)).toBe(true); }); }); }); ================================================ FILE: src/plugin/recovery.ts ================================================ /** * Session recovery hook for handling recoverable errors. * * Supports: * - tool_result_missing: When ESC is pressed during tool execution * - thinking_block_order: When thinking blocks are corrupted/stripped * - thinking_disabled_violation: Thinking in non-thinking model * * Based on oh-my-opencode/src/hooks/session-recovery/index.ts */ import type { AntigravityConfig } from "./config"; import { createLogger } from "./logger"; import { logToast } from "./debug"; import type { PluginClient } from "./types"; import { readParts, findMessagesWithThinkingBlocks, findMessagesWithOrphanThinking, findMessageByIndexNeedingThinking, prependThinkingPart, stripThinkingParts, } from "./recovery/storage"; import type { MessageInfo, MessageData, MessagePart, RecoveryErrorType, ResumeConfig, } from "./recovery/types"; // ============================================================================= // Constants // ============================================================================= const RECOVERY_RESUME_TEXT = "[session recovered - continuing previous task]"; // ============================================================================= // Error Detection // ============================================================================= /** * Extract a normalized error message string from an unknown error. */ function getErrorMessage(error: unknown): string { if (!error) return ""; if (typeof error === "string") return error.toLowerCase(); const errorObj = error as Record; const paths = [ errorObj.data, errorObj.error, errorObj, (errorObj.data as Record)?.error, ]; for (const obj of paths) { if (obj && typeof obj === "object") { const msg = (obj as Record).message; if (typeof msg === "string" && msg.length > 0) { return msg.toLowerCase(); } } } try { return JSON.stringify(error).toLowerCase(); } catch { return ""; } } /** * Extract the message index from an error message (e.g., "messages.79"). */ function extractMessageIndex(error: unknown): number | null { const message = getErrorMessage(error); const match = message.match(/messages\.(\d+)/); if (!match || !match[1]) return null; return parseInt(match[1], 10); } /** * Detect the type of recoverable error from an error object. */ export function detectErrorType(error: unknown): RecoveryErrorType { const message = getErrorMessage(error); const hasExpectedFoundThinkingOrder = (message.includes("expected thinking") || message.includes("expected a thinking")) && message.includes("found"); // tool_result_missing: Happens when ESC is pressed during tool execution if (message.includes("tool_use") && message.includes("tool_result")) { return "tool_result_missing"; } // thinking_block_order: Happens when thinking blocks are corrupted if ( message.includes("thinking") && (message.includes("first block") || message.includes("must start with") || message.includes("preceeding") || message.includes("preceding") || hasExpectedFoundThinkingOrder) ) { return "thinking_block_order"; } // thinking_disabled_violation: Thinking in non-thinking model if (message.includes("thinking is disabled") && message.includes("cannot contain")) { return "thinking_disabled_violation"; } return null; } /** * Check if an error is recoverable. */ export function isRecoverableError(error: unknown): boolean { return detectErrorType(error) !== null; } // ============================================================================= // Tool Use Extraction // ============================================================================= interface ToolUsePart { type: "tool_use"; id: string; name: string; input: Record; } function extractToolUseIds(parts: MessagePart[]): string[] { return parts .filter((p): p is ToolUsePart & MessagePart => p.type === "tool_use" && !!p.id) .map((p) => p.id!); } // ============================================================================= // Recovery Functions // ============================================================================= /** * Recover from tool_result_missing error by injecting synthetic tool_result blocks. */ async function recoverToolResultMissing( client: PluginClient, sessionID: string, failedMsg: MessageData ): Promise { // Try API parts first, fallback to filesystem if empty let parts = failedMsg.parts || []; if (parts.length === 0 && failedMsg.info?.id) { const storedParts = readParts(failedMsg.info.id); parts = storedParts.map((p) => ({ type: p.type === "tool" ? "tool_use" : p.type, id: "callID" in p ? (p as { callID?: string }).callID : p.id, name: "tool" in p ? (p as { tool?: string }).tool : undefined, input: "state" in p ? (p as { state?: { input?: Record } }).state?.input : undefined, })); } const toolUseIds = extractToolUseIds(parts); if (toolUseIds.length === 0) { return false; } const toolResultParts = toolUseIds.map((id) => ({ type: "tool_result" as const, tool_use_id: id, content: "Operation cancelled by user (ESC pressed)", })); try { await client.session.prompt({ path: { id: sessionID }, // @ts-expect-error - SDK types may not include tool_result parts body: { parts: toolResultParts }, }); return true; } catch { return false; } } /** * Recover from thinking_block_order error by prepending thinking parts. */ async function recoverThinkingBlockOrder( sessionID: string, _failedMsg: MessageData, error: unknown ): Promise { // Try to find the target message index from error const targetIndex = extractMessageIndex(error); if (targetIndex !== null) { const targetMessageID = findMessageByIndexNeedingThinking(sessionID, targetIndex); if (targetMessageID) { return prependThinkingPart(sessionID, targetMessageID); } } // Fallback: find all orphan thinking messages const orphanMessages = findMessagesWithOrphanThinking(sessionID); if (orphanMessages.length === 0) { return false; } let anySuccess = false; for (const messageID of orphanMessages) { if (prependThinkingPart(sessionID, messageID)) { anySuccess = true; } } return anySuccess; } /** * Recover from thinking_disabled_violation by stripping thinking parts. */ async function recoverThinkingDisabledViolation( sessionID: string, _failedMsg: MessageData ): Promise { const messagesWithThinking = findMessagesWithThinkingBlocks(sessionID); if (messagesWithThinking.length === 0) { return false; } let anySuccess = false; for (const messageID of messagesWithThinking) { if (stripThinkingParts(messageID)) { anySuccess = true; } } return anySuccess; } // ============================================================================= // Resume Session Helper // ============================================================================= function findLastUserMessage(messages: MessageData[]): MessageData | undefined { for (let i = messages.length - 1; i >= 0; i--) { if (messages[i]?.info?.role === "user") { return messages[i]; } } return undefined; } function extractResumeConfig(userMessage: MessageData | undefined, sessionID: string): ResumeConfig { return { sessionID, agent: userMessage?.info?.agent, model: userMessage?.info?.model, }; } async function resumeSession( client: PluginClient, config: ResumeConfig, directory: string ): Promise { try { await client.session.prompt({ path: { id: config.sessionID }, body: { parts: [{ type: "text", text: RECOVERY_RESUME_TEXT }], agent: config.agent, model: config.model, }, query: { directory }, }); return true; } catch { return false; } } // ============================================================================= // Toast Messages // ============================================================================= const TOAST_TITLES: Record = { tool_result_missing: "Tool Crash Recovery", thinking_block_order: "Thinking Block Recovery", thinking_disabled_violation: "Thinking Strip Recovery", }; const TOAST_MESSAGES: Record = { tool_result_missing: "Injecting cancelled tool results...", thinking_block_order: "Fixing message structure...", thinking_disabled_violation: "Stripping thinking blocks...", }; export function getRecoveryToastContent(errorType: RecoveryErrorType): { title: string; message: string; } { if (!errorType) { return { title: "Session Recovery", message: "Attempting to recover session...", }; } return { title: TOAST_TITLES[errorType] || "Session Recovery", message: TOAST_MESSAGES[errorType] || "Attempting to recover session...", }; } export function getRecoverySuccessToast(): { title: string; message: string; } { return { title: "Session Recovered", message: "Continuing where you left off...", }; } export function getRecoveryFailureToast(): { title: string; message: string; } { return { title: "Recovery Failed", message: "Please retry or start a new session.", }; } // ============================================================================= // Session Recovery Hook // ============================================================================= export interface SessionRecoveryHook { /** * Main recovery handler. Performs the actual fix. * Returns true if recovery was successful. */ handleSessionRecovery: (info: MessageInfo) => Promise; /** * Check if the error is recoverable. */ isRecoverableError: (error: unknown) => boolean; /** * Callback for when a session is being aborted for recovery. */ setOnAbortCallback: (callback: (sessionID: string) => void) => void; /** * Callback for when recovery is complete (success or failure). */ setOnRecoveryCompleteCallback: (callback: (sessionID: string) => void) => void; } export interface SessionRecoveryContext { client: PluginClient; directory: string; } /** * Create a session recovery hook with the given configuration. */ export function createSessionRecoveryHook( ctx: SessionRecoveryContext, config: AntigravityConfig ): SessionRecoveryHook | null { // If session recovery is disabled, return null if (!config.session_recovery) { return null; } const { client, directory } = ctx; const processingErrors = new Set(); let onAbortCallback: ((sessionID: string) => void) | null = null; let onRecoveryCompleteCallback: ((sessionID: string) => void) | null = null; const setOnAbortCallback = (callback: (sessionID: string) => void): void => { onAbortCallback = callback; }; const setOnRecoveryCompleteCallback = (callback: (sessionID: string) => void): void => { onRecoveryCompleteCallback = callback; }; const handleSessionRecovery = async (info: MessageInfo): Promise => { // Validate input if (!info || info.role !== "assistant" || !info.error) return false; const errorType = detectErrorType(info.error); if (!errorType) return false; const sessionID = info.sessionID; if (!sessionID) return false; // OpenCode's session.error event may not include messageID // In that case, we need to fetch messages and find the latest assistant with error let assistantMsgID = info.id; let msgs: MessageData[] | undefined; const log = createLogger("session-recovery"); log.debug("Recovery attempt started", { errorType, sessionID, providedMsgID: assistantMsgID ?? "none", }); // Notify abort callback early if (onAbortCallback) { onAbortCallback(sessionID); } // Abort current request await client.session.abort({ path: { id: sessionID } }).catch(() => {}); // Fetch messages - needed to find the failed message const messagesResp = await client.session.messages({ path: { id: sessionID }, query: { directory }, }); msgs = (messagesResp as { data?: MessageData[] }).data; // If messageID wasn't provided, find the latest assistant message with an error if (!assistantMsgID && msgs && msgs.length > 0) { // Find the last assistant message (most recent is typically last in array) for (let i = msgs.length - 1; i >= 0; i--) { const m = msgs[i]; if (m && m.info?.role === "assistant" && m.info?.id) { assistantMsgID = m.info.id; log.debug("Found assistant message ID from session messages", { msgID: assistantMsgID, msgIndex: i, }); break; } } } if (!assistantMsgID) { log.debug("No assistant message ID found, cannot recover"); return false; } if (processingErrors.has(assistantMsgID)) return false; processingErrors.add(assistantMsgID); try { const failedMsg = msgs?.find((m) => m.info?.id === assistantMsgID); if (!failedMsg) { return false; } // Show toast notification const toastContent = getRecoveryToastContent(errorType); logToast(`${toastContent.title}: ${toastContent.message}`, "warning"); await client.tui .showToast({ body: { title: toastContent.title, message: toastContent.message, variant: "warning", }, }) .catch(() => {}); // Perform recovery based on error type let success = false; if (errorType === "tool_result_missing") { success = await recoverToolResultMissing(client, sessionID, failedMsg); } else if (errorType === "thinking_block_order") { success = await recoverThinkingBlockOrder(sessionID, failedMsg, info.error); if (success && config.auto_resume) { const lastUser = findLastUserMessage(msgs ?? []); const resumeConfig = extractResumeConfig(lastUser, sessionID); await resumeSession(client, resumeConfig, directory); } } else if (errorType === "thinking_disabled_violation") { success = await recoverThinkingDisabledViolation(sessionID, failedMsg); if (success && config.auto_resume) { const lastUser = findLastUserMessage(msgs ?? []); const resumeConfig = extractResumeConfig(lastUser, sessionID); await resumeSession(client, resumeConfig, directory); } } return success; } catch (err) { log.error("Recovery failed", { error: String(err) }); return false; } finally { processingErrors.delete(assistantMsgID); // Always notify recovery complete if (sessionID && onRecoveryCompleteCallback) { onRecoveryCompleteCallback(sessionID); } } }; return { handleSessionRecovery, isRecoverableError, setOnAbortCallback, setOnRecoveryCompleteCallback, }; } ================================================ FILE: src/plugin/refresh-queue.test.ts ================================================ import { beforeEach, describe, expect, it, vi } from "vitest"; import { ProactiveRefreshQueue } from "./refresh-queue"; import { AccountManager } from "./accounts"; import type { AccountStorageV4 } from "./storage"; import type { PluginClient } from "./types"; // Mock PluginClient const mockClient: PluginClient = { toast: vi.fn(), auth: { get: vi.fn(), set: vi.fn(), remove: vi.fn(), }, } as unknown as PluginClient; describe("ProactiveRefreshQueue", () => { beforeEach(() => { vi.useRealTimers(); }); describe("getAccountsNeedingRefresh", () => { it("skips disabled accounts", () => { const now = Date.now(); const stored: AccountStorageV4 = { version: 4, accounts: [ { refreshToken: "r1", projectId: "p1", addedAt: now, lastUsed: 0, enabled: true, }, { refreshToken: "r2", projectId: "p2", addedAt: now, lastUsed: 0, enabled: false, // disabled account }, { refreshToken: "r3", projectId: "p3", addedAt: now, lastUsed: 0, enabled: true, }, ], activeIndex: 0, }; const manager = new AccountManager(undefined, stored); const queue = new ProactiveRefreshQueue(mockClient, "test-provider", { enabled: true, bufferSeconds: 1800, checkIntervalSeconds: 300, }); queue.setAccountManager(manager); // Set all accounts to expire soon (within buffer) const accounts = manager.getAccounts(); const expiringSoon = now + 1000 * 60 * 10; // 10 minutes from now accounts.forEach((acc) => { acc.expires = expiringSoon; }); const needsRefresh = queue.getAccountsNeedingRefresh(); // Should only include enabled accounts (indices 0 and 2) expect(needsRefresh.length).toBe(2); expect(needsRefresh.map((a) => a.index)).toEqual([0, 2]); expect(needsRefresh.every((a) => a.enabled !== false)).toBe(true); }); it("includes accounts with undefined enabled (default to enabled)", () => { const now = Date.now(); const stored: AccountStorageV4 = { version: 4, accounts: [ { refreshToken: "r1", projectId: "p1", addedAt: now, lastUsed: 0, // enabled is undefined - should be treated as enabled }, ], activeIndex: 0, }; const manager = new AccountManager(undefined, stored); const queue = new ProactiveRefreshQueue(mockClient, "test-provider", { enabled: true, bufferSeconds: 1800, checkIntervalSeconds: 300, }); queue.setAccountManager(manager); // Set account to expire soon const accounts = manager.getAccounts(); accounts[0]!.expires = now + 1000 * 60 * 10; // 10 minutes from now const needsRefresh = queue.getAccountsNeedingRefresh(); expect(needsRefresh.length).toBe(1); expect(needsRefresh[0]!.index).toBe(0); }); it("skips expired accounts", () => { const now = Date.now(); const stored: AccountStorageV4 = { version: 4, accounts: [ { refreshToken: "r1", projectId: "p1", addedAt: now, lastUsed: 0, enabled: true, }, ], activeIndex: 0, }; const manager = new AccountManager(undefined, stored); const queue = new ProactiveRefreshQueue(mockClient, "test-provider", { enabled: true, bufferSeconds: 1800, checkIntervalSeconds: 300, }); queue.setAccountManager(manager); // Set account to already expired const accounts = manager.getAccounts(); accounts[0]!.expires = now - 1000; // 1 second ago const needsRefresh = queue.getAccountsNeedingRefresh(); expect(needsRefresh.length).toBe(0); }); it("skips accounts that don't need refresh yet", () => { const now = Date.now(); const stored: AccountStorageV4 = { version: 4, accounts: [ { refreshToken: "r1", projectId: "p1", addedAt: now, lastUsed: 0, enabled: true, }, ], activeIndex: 0, }; const manager = new AccountManager(undefined, stored); const queue = new ProactiveRefreshQueue(mockClient, "test-provider", { enabled: true, bufferSeconds: 1800, // 30 minutes checkIntervalSeconds: 300, }); queue.setAccountManager(manager); // Set account to expire in 1 hour (outside 30 min buffer) const accounts = manager.getAccounts(); accounts[0]!.expires = now + 1000 * 60 * 60; // 1 hour from now const needsRefresh = queue.getAccountsNeedingRefresh(); expect(needsRefresh.length).toBe(0); }); }); }); ================================================ FILE: src/plugin/refresh-queue.ts ================================================ /** * Proactive Token Refresh Queue * * Ported from LLM-API-Key-Proxy's BackgroundRefresher. * * This module provides background token refresh to ensure OAuth tokens * remain valid without blocking user requests. It periodically checks * all accounts and refreshes tokens that are approaching expiry. * * Features: * - Non-blocking background refresh (doesn't block requests) * - Configurable refresh buffer (default: 30 minutes before expiry) * - Configurable check interval (default: 5 minutes) * - Serialized refresh to prevent concurrent refresh storms * - Integrates with existing AccountManager and token refresh logic * - Silent operation: no console output, uses structured logger */ import type { AccountManager, ManagedAccount } from "./accounts"; import type { PluginClient, OAuthAuthDetails } from "./types"; import { refreshAccessToken } from "./token"; import { createLogger } from "./logger"; const log = createLogger("refresh-queue"); /** Configuration for the proactive refresh queue */ export interface ProactiveRefreshConfig { /** Enable proactive token refresh (default: true) */ enabled: boolean; /** Seconds before expiry to trigger proactive refresh (default: 1800 = 30 minutes) */ bufferSeconds: number; /** Interval between refresh checks in seconds (default: 300 = 5 minutes) */ checkIntervalSeconds: number; } export const DEFAULT_PROACTIVE_REFRESH_CONFIG: ProactiveRefreshConfig = { enabled: true, bufferSeconds: 1800, // 30 minutes checkIntervalSeconds: 300, // 5 minutes }; /** State for tracking refresh operations */ interface RefreshQueueState { isRunning: boolean; intervalHandle: ReturnType | null; isRefreshing: boolean; lastCheckTime: number; lastRefreshTime: number; refreshCount: number; errorCount: number; } /** * Proactive Token Refresh Queue * * Runs in the background and proactively refreshes tokens before they expire. * This ensures that user requests never block on token refresh. * * All logging is silent by default - uses structured logger with TUI integration. */ export class ProactiveRefreshQueue { private readonly config: ProactiveRefreshConfig; private readonly client: PluginClient; private readonly providerId: string; private accountManager: AccountManager | null = null; private state: RefreshQueueState = { isRunning: false, intervalHandle: null, isRefreshing: false, lastCheckTime: 0, lastRefreshTime: 0, refreshCount: 0, errorCount: 0, }; constructor( client: PluginClient, providerId: string, config?: Partial, ) { this.client = client; this.providerId = providerId; this.config = { ...DEFAULT_PROACTIVE_REFRESH_CONFIG, ...config, }; } /** * Set the account manager to use for refresh operations. * Must be called before start(). */ setAccountManager(manager: AccountManager): void { this.accountManager = manager; } /** * Check if a token needs proactive refresh. * Returns true if the token expires within the buffer period. */ needsRefresh(account: ManagedAccount): boolean { if (!account.expires) { // No expiry set - assume it's fine return false; } const now = Date.now(); const bufferMs = this.config.bufferSeconds * 1000; const refreshThreshold = now + bufferMs; return account.expires <= refreshThreshold; } /** * Check if a token is already expired. */ isExpired(account: ManagedAccount): boolean { if (!account.expires) { return false; } return account.expires <= Date.now(); } /** * Get all accounts that need proactive refresh. */ getAccountsNeedingRefresh(): ManagedAccount[] { if (!this.accountManager) { return []; } return this.accountManager.getAccounts().filter((account) => { // Skip disabled accounts - they shouldn't receive proactive refresh if (account.enabled === false) { return false; } // Only refresh if not already expired (let the main flow handle expired tokens) if (this.isExpired(account)) { return false; } return this.needsRefresh(account); }); } /** * Perform a single refresh check iteration. * This is called periodically by the background interval. */ private async runRefreshCheck(): Promise { if (this.state.isRefreshing) { // Already refreshing - skip this iteration return; } if (!this.accountManager) { return; } this.state.isRefreshing = true; this.state.lastCheckTime = Date.now(); try { const accountsToRefresh = this.getAccountsNeedingRefresh(); if (accountsToRefresh.length === 0) { return; } log.debug("Found accounts needing refresh", { count: accountsToRefresh.length }); // Refresh accounts serially to avoid concurrent refresh storms for (const account of accountsToRefresh) { if (!this.state.isRunning) { // Queue was stopped - abort break; } try { const auth = this.accountManager.toAuthDetails(account); const refreshed = await this.refreshToken(auth, account); if (refreshed) { this.accountManager.updateFromAuth(account, refreshed); this.state.refreshCount++; this.state.lastRefreshTime = Date.now(); // Persist the refreshed token try { await this.accountManager.saveToDisk(); } catch { // Non-fatal - token is refreshed in memory } } } catch (error) { this.state.errorCount++; // Log but don't throw - continue with other accounts log.warn("Failed to refresh account", { accountIndex: account.index, error: error instanceof Error ? error.message : String(error), }); } } } finally { this.state.isRefreshing = false; } } /** * Refresh a single token. */ private async refreshToken( auth: OAuthAuthDetails, account: ManagedAccount, ): Promise { const minutesUntilExpiry = account.expires ? Math.round((account.expires - Date.now()) / 60000) : "unknown"; log.debug("Proactively refreshing token", { accountIndex: account.index, email: account.email ?? "unknown", minutesUntilExpiry, }); return refreshAccessToken(auth, this.client, this.providerId); } /** * Start the background refresh queue. */ start(): void { if (this.state.isRunning) { return; } if (!this.config.enabled) { log.debug("Proactive refresh disabled by config"); return; } this.state.isRunning = true; const intervalMs = this.config.checkIntervalSeconds * 1000; log.debug("Started proactive refresh queue", { checkIntervalSeconds: this.config.checkIntervalSeconds, bufferSeconds: this.config.bufferSeconds, }); // Run initial check after a short delay (let things settle) setTimeout(() => { if (this.state.isRunning) { this.runRefreshCheck().catch((error) => { log.error("Initial check failed", { error: error instanceof Error ? error.message : String(error), }); }); } }, 5000); // Set up periodic checks this.state.intervalHandle = setInterval(() => { this.runRefreshCheck().catch((error) => { log.error("Check failed", { error: error instanceof Error ? error.message : String(error), }); }); }, intervalMs); } /** * Stop the background refresh queue. */ stop(): void { if (!this.state.isRunning) { return; } this.state.isRunning = false; if (this.state.intervalHandle) { clearInterval(this.state.intervalHandle); this.state.intervalHandle = null; } log.debug("Stopped proactive refresh queue", { refreshCount: this.state.refreshCount, errorCount: this.state.errorCount, }); } /** * Get current queue statistics. */ getStats(): { isRunning: boolean; isRefreshing: boolean; lastCheckTime: number; lastRefreshTime: number; refreshCount: number; errorCount: number; } { return { ...this.state }; } /** * Check if the queue is currently running. */ isRunning(): boolean { return this.state.isRunning; } } /** * Create a proactive refresh queue instance. */ export function createProactiveRefreshQueue( client: PluginClient, providerId: string, config?: Partial, ): ProactiveRefreshQueue { return new ProactiveRefreshQueue(client, providerId, config); } ================================================ FILE: src/plugin/request-helpers.test.ts ================================================ import { describe, expect, it } from "vitest"; import { isThinkingCapableModel, extractThinkingConfig, extractVariantThinkingConfig, resolveThinkingConfig, filterUnsignedThinkingBlocks, filterMessagesThinkingBlocks, deepFilterThinkingBlocks, transformThinkingParts, normalizeThinkingConfig, parseAntigravityApiBody, extractUsageMetadata, extractUsageFromSsePayload, rewriteAntigravityPreviewAccessError, DEFAULT_THINKING_BUDGET, findOrphanedToolUseIds, fixClaudeToolPairing, validateAndFixClaudeToolPairing, injectParameterSignatures, injectToolHardeningInstruction, cleanJSONSchemaForAntigravity, createSyntheticErrorResponse, recursivelyParseJsonStrings, } from "./request-helpers"; import { deduplicateThinkingText, createThoughtBuffer } from "./core/streaming/transformer"; describe("sanitizeThinkingPart (covered via filtering)", () => { it("extracts wrapped text and strips SDK fields for Gemini-style thought blocks", () => { const validSignature = "s".repeat(60); const thinkingText = "wrapped thought"; const getCachedSignatureFn = (_sessionId: string, text: string) => text === thinkingText ? validSignature : undefined; const contents = [ { role: "model", parts: [ { thought: true, text: { text: thinkingText, cache_control: { type: "ephemeral" }, providerOptions: { injected: true }, }, thoughtSignature: validSignature, cache_control: { type: "ephemeral" }, providerOptions: { injected: true }, }, ], }, { role: "model", parts: [{ text: "trailing" }] }, ]; const result = filterUnsignedThinkingBlocks(contents, "session-1", getCachedSignatureFn) as any; expect(result[0].parts).toHaveLength(1); expect(result[0].parts[0]).toEqual({ thought: true, text: thinkingText, thoughtSignature: validSignature, }); expect(result[0].parts[0].cache_control).toBeUndefined(); expect(result[0].parts[0].providerOptions).toBeUndefined(); }); it("extracts wrapped thinking text and strips SDK fields for Anthropic-style thinking blocks", () => { const validSignature = "a".repeat(60); const thinkingText = "wrapped thinking"; const getCachedSignatureFn = (_sessionId: string, text: string) => text === thinkingText ? validSignature : undefined; const contents = [ { role: "model", parts: [ { type: "thinking", thinking: { text: thinkingText, cache_control: { type: "ephemeral" }, providerOptions: { injected: true }, }, signature: validSignature, cache_control: { type: "ephemeral" }, providerOptions: { injected: true }, }, ], }, { role: "model", parts: [{ text: "trailing" }] }, ]; const result = filterUnsignedThinkingBlocks(contents, "session-1", getCachedSignatureFn) as any; expect(result[0].parts).toHaveLength(1); expect(result[0].parts[0]).toEqual({ type: "thinking", thinking: thinkingText, signature: validSignature, }); }); it("preserves signatures while dropping cache_control/providerOptions during signature restoration", () => { const cachedSignature = "c".repeat(60); const getCachedSignatureFn = (_sessionId: string, _text: string) => cachedSignature; const messages = [ { role: "assistant", content: [ { type: "thinking", thinking: { thinking: "restore me", cache_control: { type: "ephemeral" }, }, providerOptions: { injected: true }, }, { type: "text", text: "visible" }, ], }, { role: "user", content: [{ type: "text", text: "next" }] }, { role: "assistant", content: [{ type: "text", text: "last" }] }, ]; const result = filterMessagesThinkingBlocks(messages, "session-1", getCachedSignatureFn) as any; expect(result[0].content[0]).toEqual({ type: "thinking", thinking: "restore me", signature: cachedSignature, }); }); it("sanitizes reasoning blocks keeping only allowed fields (type, text, signature)", () => { const validSignature = "z".repeat(60); const getCachedSignatureFn = (_sessionId: string, _text: string) => validSignature; const contents = [ { role: "model", parts: [ { type: "reasoning", text: "reasoning text", signature: validSignature, cache_control: { type: "ephemeral" }, providerOptions: { injected: true }, meta: { keep: true }, }, { type: "text", text: "visible" }, ], }, { role: "user", parts: [{ text: "next" }] }, { role: "model", parts: [{ text: "last" }] }, ]; const result = filterUnsignedThinkingBlocks(contents, "session-1", getCachedSignatureFn) as any; expect(result[0].parts[0]).toEqual({ type: "reasoning", text: "reasoning text", signature: validSignature, }); }); }); describe("isThinkingCapableModel", () => { it("returns true for models with 'thinking' in name", () => { expect(isThinkingCapableModel("claude-thinking")).toBe(true); expect(isThinkingCapableModel("CLAUDE-THINKING-4")).toBe(true); expect(isThinkingCapableModel("model-thinking-v1")).toBe(true); }); it("returns true for models with 'gemini-3' in name", () => { expect(isThinkingCapableModel("gemini-3-pro")).toBe(true); expect(isThinkingCapableModel("GEMINI-3-flash")).toBe(true); expect(isThinkingCapableModel("gemini-3")).toBe(true); }); it("returns true for models with 'opus' in name", () => { expect(isThinkingCapableModel("claude-opus")).toBe(true); expect(isThinkingCapableModel("claude-4-opus")).toBe(true); expect(isThinkingCapableModel("OPUS")).toBe(true); }); it("returns false for non-thinking models", () => { expect(isThinkingCapableModel("claude-sonnet")).toBe(false); expect(isThinkingCapableModel("gemini-2-pro")).toBe(false); expect(isThinkingCapableModel("gpt-4")).toBe(false); }); }); describe("extractThinkingConfig", () => { it("extracts thinkingConfig from generationConfig", () => { const result = extractThinkingConfig( {}, { thinkingConfig: { includeThoughts: true, thinkingBudget: 8000 } }, undefined, ); expect(result).toEqual({ includeThoughts: true, thinkingBudget: 8000 }); }); it("extracts thinkingConfig from extra_body", () => { const result = extractThinkingConfig( {}, undefined, { thinkingConfig: { includeThoughts: true, thinkingBudget: 4000 } }, ); expect(result).toEqual({ includeThoughts: true, thinkingBudget: 4000 }); }); it("extracts thinkingConfig from requestPayload directly", () => { const result = extractThinkingConfig( { thinkingConfig: { includeThoughts: false, thinkingBudget: 2000 } }, undefined, undefined, ); expect(result).toEqual({ includeThoughts: false, thinkingBudget: 2000 }); }); it("prioritizes generationConfig over extra_body", () => { const result = extractThinkingConfig( {}, { thinkingConfig: { includeThoughts: true, thinkingBudget: 8000 } }, { thinkingConfig: { includeThoughts: false, thinkingBudget: 4000 } }, ); expect(result).toEqual({ includeThoughts: true, thinkingBudget: 8000 }); }); it("converts Anthropic-style thinking config", () => { const result = extractThinkingConfig( { thinking: { type: "enabled", budgetTokens: 10000 } }, undefined, undefined, ); expect(result).toEqual({ includeThoughts: true, thinkingBudget: 10000 }); }); it("uses default budget for Anthropic-style without budgetTokens", () => { const result = extractThinkingConfig( { thinking: { type: "enabled" } }, undefined, undefined, ); expect(result).toEqual({ includeThoughts: true, thinkingBudget: DEFAULT_THINKING_BUDGET }); }); it("returns undefined when no config found", () => { expect(extractThinkingConfig({}, undefined, undefined)).toBeUndefined(); }); it("uses default budget when thinkingBudget not specified", () => { const result = extractThinkingConfig( {}, { thinkingConfig: { includeThoughts: true } }, undefined, ); expect(result).toEqual({ includeThoughts: true, thinkingBudget: DEFAULT_THINKING_BUDGET }); }); }); describe("resolveThinkingConfig", () => { it("keeps thinking enabled for Claude models with assistant history", () => { const result = resolveThinkingConfig( { includeThoughts: true, thinkingBudget: 8000 }, true, // isThinkingModel true, // isClaudeModel true, // hasAssistantHistory ); expect(result).toEqual({ includeThoughts: true, thinkingBudget: 8000 }); }); it("enables thinking for thinking-capable models without user config", () => { const result = resolveThinkingConfig( undefined, true, // isThinkingModel false, // isClaudeModel false, // hasAssistantHistory ); expect(result).toEqual({ includeThoughts: true, thinkingBudget: DEFAULT_THINKING_BUDGET }); }); it("respects user config for non-Claude models", () => { const userConfig = { includeThoughts: false, thinkingBudget: 5000 }; const result = resolveThinkingConfig( userConfig, true, false, false, ); expect(result).toEqual(userConfig); }); it("returns user config for Claude without history", () => { const userConfig = { includeThoughts: true, thinkingBudget: 8000 }; const result = resolveThinkingConfig( userConfig, true, true, // isClaudeModel false, // no history ); expect(result).toEqual(userConfig); }); it("returns undefined for non-thinking model without user config", () => { const result = resolveThinkingConfig( undefined, false, // not thinking model false, false, ); expect(result).toBeUndefined(); }); }); describe("filterUnsignedThinkingBlocks", () => { it("filters out unsigned thinking parts", () => { const contents = [ { role: "model", parts: [ { type: "thinking", text: "thinking without signature" }, { type: "text", text: "visible text" }, ], }, { role: "user", parts: [{ text: "next" }] }, { role: "model", parts: [{ text: "last" }] }, ]; const result = filterUnsignedThinkingBlocks(contents); expect(result[0].parts).toHaveLength(1); expect(result[0].parts[0].type).toBe("text"); }); it("keeps signed thinking parts with valid signatures from our cache", () => { const validSignature = "a".repeat(60); const thinkingText = "thinking with signature"; const getCachedSignatureFn = (_sessionId: string, text: string) => text === thinkingText ? validSignature : undefined; const contents = [ { role: "model", parts: [ { type: "thinking", text: thinkingText, signature: validSignature }, { type: "text", text: "visible text" }, ], }, ]; const result = filterUnsignedThinkingBlocks(contents, "session-1", getCachedSignatureFn); expect(result[0].parts).toHaveLength(2); expect(result[0].parts[0].signature).toBe(validSignature); }); it("strips thinking parts with foreign signatures not in our cache", () => { const foreignSignature = "f".repeat(60); const contents = [ { role: "model", parts: [ { type: "thinking", text: "foreign thinking", signature: foreignSignature }, { type: "text", text: "visible text" }, ], }, { role: "user", parts: [{ text: "next" }] }, { role: "model", parts: [{ text: "last" }] }, ]; const result = filterUnsignedThinkingBlocks(contents); expect(result[0].parts).toHaveLength(1); expect(result[0].parts[0].type).toBe("text"); }); it("filters thinking parts with short signatures", () => { const contents = [ { role: "model", parts: [ { type: "thinking", text: "thinking with short signature", signature: "sig123" }, { type: "text", text: "visible text" }, ], }, { role: "user", parts: [{ text: "next" }] }, { role: "model", parts: [{ text: "last" }] }, ]; const result = filterUnsignedThinkingBlocks(contents); expect(result[0].parts).toHaveLength(1); expect(result[0].parts[0].type).toBe("text"); }); it("handles Gemini-style thought parts with valid signatures from our cache", () => { const validSignature = "b".repeat(55); const thinkingText = "has signature"; const getCachedSignatureFn = (_sessionId: string, text: string) => text === thinkingText ? validSignature : undefined; const contents = [ { role: "model", parts: [ { thought: true, text: "no signature" }, { thought: true, text: thinkingText, thoughtSignature: validSignature }, ], }, { role: "user", parts: [{ text: "next" }] }, { role: "model", parts: [{ text: "last" }] }, ]; const result = filterUnsignedThinkingBlocks(contents, "session-1", getCachedSignatureFn); expect(result[0].parts).toHaveLength(1); expect(result[0].parts[0].thoughtSignature).toBe(validSignature); }); it("filters Gemini-style thought parts with short signatures", () => { const contents = [ { role: "model", parts: [ { thought: true, text: "has short signature", thoughtSignature: "sig" }, ], }, ]; const result = filterUnsignedThinkingBlocks(contents); expect(result[0].parts).toHaveLength(0); }); it("preserves non-thinking parts", () => { const contents = [ { role: "user", parts: [{ text: "hello" }], }, ]; const result = filterUnsignedThinkingBlocks(contents); expect(result).toEqual(contents); }); it("strips blocks with signature field even if type is unknown", () => { const foreignSignature = "x".repeat(60); const contents = [ { role: "model", parts: [ { type: "unknown_thinking_type", text: "foreign block", signature: foreignSignature }, { type: "text", text: "visible" }, ], }, { role: "user", parts: [{ text: "next" }] }, { role: "model", parts: [{ text: "last" }] }, ]; const result = filterUnsignedThinkingBlocks(contents); expect(result[0].parts).toHaveLength(1); expect(result[0].parts[0].type).toBe("text"); }); it("handles empty parts array", () => { const contents = [{ role: "model", parts: [] }]; const result = filterUnsignedThinkingBlocks(contents); expect(result[0].parts).toEqual([]); }); it("handles missing parts", () => { const contents = [{ role: "model" }]; const result = filterUnsignedThinkingBlocks(contents); expect(result).toEqual(contents); }); it("preserves tool_use and tool_result blocks intact", () => { const contents = [ { role: "model", parts: [ { type: "tool_use", id: "tool_123", name: "bash", input: { command: "ls" } }, ], }, { role: "user", parts: [ { type: "tool_result", tool_use_id: "tool_123", content: "file1.txt" }, ], }, ]; const result = filterUnsignedThinkingBlocks(contents); expect(result[0].parts[0]).toEqual({ type: "tool_use", id: "tool_123", name: "bash", input: { command: "ls" } }); expect(result[1].parts[0]).toEqual({ type: "tool_result", tool_use_id: "tool_123", content: "file1.txt" }); }); it("preserves tool blocks even if they have signature-like fields", () => { const contents = [ { role: "user", parts: [ { type: "tool_result", tool_use_id: "tool_456", content: "result", signature: "some_random_value" }, ], }, ]; const result = filterUnsignedThinkingBlocks(contents); expect(result[0].parts).toHaveLength(1); expect(result[0].parts[0].tool_use_id).toBe("tool_456"); }); it("preserves nested tool_result format", () => { const contents = [ { role: "user", parts: [ { tool_result: { tool_use_id: "tool_789", content: "nested result" } }, ], }, ]; const result = filterUnsignedThinkingBlocks(contents); expect(result[0].parts).toHaveLength(1); expect(result[0].parts[0].tool_result.tool_use_id).toBe("tool_789"); }); it("preserves functionCall and functionResponse blocks", () => { const contents = [ { role: "model", parts: [ { functionCall: { name: "get_weather", args: { city: "NYC" } } }, ], }, { role: "function", parts: [ { functionResponse: { name: "get_weather", response: { temp: 72 } } }, ], }, ]; const result = filterUnsignedThinkingBlocks(contents); expect(result[0].parts[0].functionCall).toBeDefined(); expect(result[1].parts[0].functionResponse).toBeDefined(); }); }); describe("deepFilterThinkingBlocks", () => { it("removes nested thinking blocks in extra_body messages", () => { const payload = { extra_body: { messages: [ { role: "assistant", content: [ { type: "thinking", thinking: "foreign", signature: "x".repeat(60) }, { type: "text", text: "visible" }, ], }, { role: "assistant", content: [{ type: "text", text: "last" }] }, ], }, }; deepFilterThinkingBlocks(payload); const filtered = (payload as any).extra_body.messages[0].content; expect(filtered).toHaveLength(1); expect(filtered[0].type).toBe("text"); }); }); describe("filterMessagesThinkingBlocks", () => { it("filters out unsigned thinking blocks in messages[].content", () => { const messages = [ { role: "assistant", content: [ { type: "thinking", thinking: "no signature" }, { type: "text", text: "visible" }, ], }, { role: "assistant", content: [{ type: "text", text: "last" }] }, ]; const result = filterMessagesThinkingBlocks(messages) as any; expect(result[0].content).toHaveLength(1); expect(result[0].content[0].type).toBe("text"); }); it("keeps signed thinking blocks with valid signatures from our cache and sanitizes injected fields", () => { const validSignature = "a".repeat(60); const thinkingText = "wrapped"; const getCachedSignatureFn = (_sessionId: string, text: string) => text === thinkingText ? validSignature : undefined; const messages = [ { role: "assistant", content: [ { type: "thinking", thinking: { text: thinkingText, cache_control: { type: "ephemeral" } }, signature: validSignature, cache_control: { type: "ephemeral" }, providerOptions: { injected: true }, }, { type: "text", text: "visible" }, ], }, { role: "assistant", content: [{ type: "text", text: "last" }] }, ]; const result = filterMessagesThinkingBlocks(messages, "session-1", getCachedSignatureFn) as any; expect(result[0].content[0]).toEqual({ type: "thinking", thinking: thinkingText, signature: validSignature, }); }); it("strips thinking blocks with foreign signatures not in our cache", () => { const foreignSignature = "f".repeat(60); const messages = [ { role: "assistant", content: [ { type: "thinking", thinking: "foreign thinking", signature: foreignSignature, }, { type: "text", text: "visible" }, ], }, { role: "assistant", content: [{ type: "text", text: "last" }] }, ]; const result = filterMessagesThinkingBlocks(messages) as any; expect(result[0].content).toHaveLength(1); expect(result[0].content[0].type).toBe("text"); }); it("filters thinking blocks with short signatures", () => { const messages = [ { role: "assistant", content: [ { type: "thinking", thinking: "short sig", signature: "sig123" }, { type: "text", text: "visible" }, ], }, { role: "assistant", content: [{ type: "text", text: "last" }] }, ]; const result = filterMessagesThinkingBlocks(messages) as any; expect(result[0].content).toEqual([{ type: "text", text: "visible" }]); }); it("restores a missing signature from cache and preserves it after sanitization", () => { const cachedSignature = "c".repeat(60); const getCachedSignatureFn = (_sessionId: string, _text: string) => cachedSignature; const messages = [ { role: "assistant", content: [ { type: "thinking", thinking: { thinking: "restore me", providerOptions: { injected: true } }, // no signature present (forces restore) cache_control: { type: "ephemeral" }, }, { type: "text", text: "visible" }, ], }, { role: "assistant", content: [{ type: "text", text: "last" }] }, ]; const result = filterMessagesThinkingBlocks(messages, "session-1", getCachedSignatureFn) as any; expect(result[0].content[0]).toEqual({ type: "thinking", thinking: "restore me", signature: cachedSignature, }); }); it("handles Gemini-style thought blocks inside messages content with cached signatures", () => { const validSignature = "b".repeat(60); const thinkingText = "wrapped thought"; const getCachedSignatureFn = (_sessionId: string, text: string) => text === thinkingText ? validSignature : undefined; const messages = [ { role: "assistant", content: [ { thought: true, text: { text: thinkingText, cache_control: { type: "ephemeral" } }, thoughtSignature: validSignature, providerOptions: { injected: true }, }, { type: "text", text: "visible" }, ], }, { role: "assistant", content: [{ type: "text", text: "last" }] }, ]; const result = filterMessagesThinkingBlocks(messages, "session-1", getCachedSignatureFn) as any; expect(result[0].content[0]).toEqual({ thought: true, text: thinkingText, thoughtSignature: validSignature, }); }); it("preserves non-thinking blocks and returns message unchanged when content is missing", () => { const messages: any[] = [ { role: "assistant", content: [{ type: "text", text: "hello" }] }, { role: "assistant" }, ]; const result = filterMessagesThinkingBlocks(messages) as any; expect(result[0]).toEqual(messages[0]); expect(result[1]).toEqual(messages[1]); }); it("handles non-object messages gracefully", () => { const messages: any[] = [null, "string", 123, { role: "assistant", content: [] }]; const result = filterMessagesThinkingBlocks(messages) as any; expect(result).toEqual(messages); }); }); describe("transformThinkingParts", () => { it("transforms Anthropic-style thinking blocks to reasoning", () => { const response = { content: [ { type: "thinking", thinking: "my thoughts" }, { type: "text", text: "visible" }, ], }; const result = transformThinkingParts(response) as any; expect(result.content[0].type).toBe("reasoning"); expect(result.content[0].thought).toBe(true); expect(result.reasoning_content).toBe("my thoughts"); }); it("transforms Gemini-style candidates", () => { const response = { candidates: [ { content: { parts: [ { thought: true, text: "thinking here" }, { text: "output" }, ], }, }, ], }; const result = transformThinkingParts(response) as any; expect(result.candidates[0].content.parts[0].type).toBe("reasoning"); expect(result.candidates[0].reasoning_content).toBe("thinking here"); }); it("handles non-object input", () => { expect(transformThinkingParts(null)).toBeNull(); expect(transformThinkingParts(undefined)).toBeUndefined(); expect(transformThinkingParts("string")).toBe("string"); }); it("preserves other response properties", () => { const response = { content: [], id: "resp-123", model: "claude-4", }; const result = transformThinkingParts(response) as any; expect(result.id).toBe("resp-123"); expect(result.model).toBe("claude-4"); }); it("converts Gemini-style thoughtSignature to providerMetadata.anthropic.signature", () => { const response = { candidates: [ { content: { parts: [ { thought: true, text: "thinking here", thoughtSignature: "sig123abc" }, { text: "output" }, ], }, }, ], }; const result = transformThinkingParts(response) as any; expect(result.candidates[0].content.parts[0].providerMetadata).toEqual({ anthropic: { signature: "sig123abc" } }); expect(result.candidates[0].content.parts[0].thoughtSignature).toBeUndefined(); }); it("converts Anthropic-style signature to providerMetadata.anthropic.signature", () => { const response = { candidates: [ { content: { parts: [ { type: "thinking", text: "thinking here", signature: "anthro_sig_xyz" }, { text: "output" }, ], }, }, ], }; const result = transformThinkingParts(response) as any; expect(result.candidates[0].content.parts[0].providerMetadata).toEqual({ anthropic: { signature: "anthro_sig_xyz" } }); expect(result.candidates[0].content.parts[0].signature).toBeUndefined(); }); it("converts signature in content array (Anthropic-style)", () => { const response = { content: [ { type: "thinking", thinking: "my thoughts", signature: "content_sig" }, { type: "text", text: "visible" }, ], }; const result = transformThinkingParts(response) as any; expect(result.content[0].providerMetadata).toEqual({ anthropic: { signature: "content_sig" } }); expect(result.content[0].signature).toBeUndefined(); expect(result.content[0].thoughtSignature).toBeUndefined(); }); it("prefers signature over thoughtSignature when both present", () => { const response = { candidates: [ { content: { parts: [ { thought: true, text: "thinking", signature: "sig_primary", thoughtSignature: "sig_fallback" }, ], }, }, ], }; const result = transformThinkingParts(response) as any; expect(result.candidates[0].content.parts[0].providerMetadata).toEqual({ anthropic: { signature: "sig_primary" } }); }); it("does not add providerMetadata when no signature present", () => { const response = { candidates: [ { content: { parts: [ { thought: true, text: "thinking without signature" }, { text: "output" }, ], }, }, ], }; const result = transformThinkingParts(response) as any; expect(result.candidates[0].content.parts[0].providerMetadata).toBeUndefined(); }); }); describe("normalizeThinkingConfig", () => { it("returns undefined for non-object input", () => { expect(normalizeThinkingConfig(null)).toBeUndefined(); expect(normalizeThinkingConfig(undefined)).toBeUndefined(); expect(normalizeThinkingConfig("string")).toBeUndefined(); }); it("normalizes valid config", () => { const result = normalizeThinkingConfig({ thinkingBudget: 8000, includeThoughts: true, }); expect(result).toEqual({ thinkingBudget: 8000, includeThoughts: true, }); }); it("handles snake_case property names", () => { const result = normalizeThinkingConfig({ thinking_budget: 4000, include_thoughts: true, }); expect(result).toEqual({ thinkingBudget: 4000, includeThoughts: true, }); }); it("disables includeThoughts when budget is 0", () => { const result = normalizeThinkingConfig({ thinkingBudget: 0, includeThoughts: true, }); expect(result?.includeThoughts).toBe(false); }); it("returns undefined when both values are absent/undefined", () => { const result = normalizeThinkingConfig({}); expect(result).toBeUndefined(); }); it("handles non-finite budget values", () => { const result = normalizeThinkingConfig({ thinkingBudget: Infinity, includeThoughts: true, }); // When budget is non-finite (undefined), includeThoughts is forced to false expect(result).toEqual({ includeThoughts: false }); }); }); describe("parseAntigravityApiBody", () => { it("parses valid JSON object", () => { const result = parseAntigravityApiBody('{"response": {"text": "hello"}}'); expect(result).toEqual({ response: { text: "hello" } }); }); it("extracts first object from array", () => { const result = parseAntigravityApiBody('[{"response": "first"}, {"response": "second"}]'); expect(result).toEqual({ response: "first" }); }); it("returns null for invalid JSON", () => { expect(parseAntigravityApiBody("not json")).toBeNull(); }); it("returns null for empty array", () => { expect(parseAntigravityApiBody("[]")).toBeNull(); }); it("returns null for primitive values", () => { expect(parseAntigravityApiBody('"string"')).toBeNull(); expect(parseAntigravityApiBody("123")).toBeNull(); }); it("handles array with null values", () => { const result = parseAntigravityApiBody('[null, {"valid": true}]'); expect(result).toEqual({ valid: true }); }); }); describe("extractUsageMetadata", () => { it("extracts usage from response.usageMetadata", () => { const body = { response: { usageMetadata: { totalTokenCount: 1000, promptTokenCount: 500, candidatesTokenCount: 500, cachedContentTokenCount: 100, }, }, }; const result = extractUsageMetadata(body); expect(result).toEqual({ totalTokenCount: 1000, promptTokenCount: 500, candidatesTokenCount: 500, cachedContentTokenCount: 100, }); }); it("returns null when no usageMetadata", () => { expect(extractUsageMetadata({ response: {} })).toBeNull(); expect(extractUsageMetadata({})).toBeNull(); }); it("handles partial usage data", () => { const body = { response: { usageMetadata: { totalTokenCount: 1000, }, }, }; const result = extractUsageMetadata(body); expect(result).toEqual({ totalTokenCount: 1000, promptTokenCount: undefined, candidatesTokenCount: undefined, cachedContentTokenCount: undefined, }); }); it("filters non-finite numbers", () => { const body = { response: { usageMetadata: { totalTokenCount: Infinity, promptTokenCount: NaN, candidatesTokenCount: 100, }, }, }; const result = extractUsageMetadata(body); expect(result?.totalTokenCount).toBeUndefined(); expect(result?.promptTokenCount).toBeUndefined(); expect(result?.candidatesTokenCount).toBe(100); }); }); describe("extractUsageFromSsePayload", () => { it("extracts usage from SSE data line", () => { const payload = `data: {"response": {"usageMetadata": {"totalTokenCount": 500}}}`; const result = extractUsageFromSsePayload(payload); expect(result?.totalTokenCount).toBe(500); }); it("handles multiple SSE lines", () => { const payload = `data: {"response": {}} data: {"response": {"usageMetadata": {"totalTokenCount": 1000}}}`; const result = extractUsageFromSsePayload(payload); expect(result?.totalTokenCount).toBe(1000); }); it("returns null when no usage found", () => { const payload = `data: {"response": {"text": "hello"}}`; const result = extractUsageFromSsePayload(payload); expect(result).toBeNull(); }); it("ignores non-data lines", () => { const payload = `: keepalive event: message data: {"response": {"usageMetadata": {"totalTokenCount": 200}}}`; const result = extractUsageFromSsePayload(payload); expect(result?.totalTokenCount).toBe(200); }); it("handles malformed JSON gracefully", () => { const payload = `data: not json data: {"response": {"usageMetadata": {"totalTokenCount": 300}}}`; const result = extractUsageFromSsePayload(payload); expect(result?.totalTokenCount).toBe(300); }); }); describe("rewriteAntigravityPreviewAccessError", () => { it("returns null for non-404 status", () => { const body = { error: { message: "Not found" } }; expect(rewriteAntigravityPreviewAccessError(body, 400)).toBeNull(); expect(rewriteAntigravityPreviewAccessError(body, 500)).toBeNull(); }); it("rewrites error for Antigravity model on 404", () => { const body = { error: { message: "Model not found" } }; const result = rewriteAntigravityPreviewAccessError(body, 404, "claude-opus"); expect(result?.error?.message).toContain("Model not found"); expect(result?.error?.message).toContain("preview access"); }); it("rewrites error when error message contains antigravity", () => { const body = { error: { message: "antigravity model unavailable" } }; const result = rewriteAntigravityPreviewAccessError(body, 404); expect(result?.error?.message).toContain("preview access"); }); it("returns null for 404 with non-antigravity model", () => { const body = { error: { message: "Model not found" } }; const result = rewriteAntigravityPreviewAccessError(body, 404, "gemini-pro"); expect(result).toBeNull(); }); it("provides default message when error message is empty", () => { const body = { error: { message: "" } }; const result = rewriteAntigravityPreviewAccessError(body, 404, "opus-model"); expect(result?.error?.message).toContain("Antigravity preview features are not enabled"); }); it("detects Claude models in requested model name", () => { const body = { error: {} }; const result = rewriteAntigravityPreviewAccessError(body, 404, "claude-3-sonnet"); expect(result?.error?.message).toContain("preview access"); }); }); describe("findOrphanedToolUseIds", () => { it("returns empty set when no tool_use blocks", () => { const messages = [ { role: "user", content: "Hello" }, { role: "assistant", content: "Hi there!" }, ]; const result = findOrphanedToolUseIds(messages); expect(result.size).toBe(0); }); it("returns empty set when all tool_use have matching tool_result", () => { const messages = [ { role: "assistant", content: [{ type: "tool_use", id: "tool-1", name: "read", input: {} }], }, { role: "user", content: [{ type: "tool_result", tool_use_id: "tool-1", content: "ok" }], }, ]; const result = findOrphanedToolUseIds(messages); expect(result.size).toBe(0); }); it("finds orphaned tool_use without matching tool_result", () => { const messages = [ { role: "assistant", content: [ { type: "tool_use", id: "tool-1", name: "read", input: {} }, { type: "tool_use", id: "tool-2", name: "bash", input: {} }, ], }, { role: "user", content: [{ type: "tool_result", tool_use_id: "tool-1", content: "ok" }], }, ]; const result = findOrphanedToolUseIds(messages); expect(result.size).toBe(1); expect(result.has("tool-2")).toBe(true); }); }); describe("fixClaudeToolPairing", () => { it("does not modify messages without tool_use", () => { const messages = [ { role: "user", content: "Hello" }, { role: "assistant", content: "Hi there!" }, ]; const result = fixClaudeToolPairing(messages); expect(result).toEqual(messages); }); it("does not modify properly paired tool calls", () => { const messages = [ { role: "user", content: "Check file" }, { role: "assistant", content: [ { type: "text", text: "Let me check..." }, { type: "tool_use", id: "tool-1", name: "read", input: { path: "/foo" } }, ], }, { role: "user", content: [{ type: "tool_result", tool_use_id: "tool-1", content: "file contents" }], }, ]; const result = fixClaudeToolPairing(messages); expect(result).toEqual(messages); }); it("injects placeholder for single orphaned tool_use", () => { const messages = [ { role: "user", content: "Check file" }, { role: "assistant", content: [{ type: "tool_use", id: "tool-1", name: "read", input: {} }], }, { role: "user", content: [{ type: "text", text: "continue" }] }, ]; const result = fixClaudeToolPairing(messages); expect(result.length).toBe(3); expect(result[2].content[0].type).toBe("tool_result"); expect(result[2].content[0].tool_use_id).toBe("tool-1"); expect(result[2].content[0].is_error).toBe(true); expect(result[2].content[1].type).toBe("text"); }); it("handles multiple orphaned tools in same message", () => { const messages = [ { role: "assistant", content: [ { type: "tool_use", id: "tool-1", name: "read", input: {} }, { type: "tool_use", id: "tool-2", name: "bash", input: {} }, ], }, { role: "user", content: [{ type: "text", text: "continue" }] }, ]; const result = fixClaudeToolPairing(messages); expect(result[1].content.length).toBe(3); expect(result[1].content[0].tool_use_id).toBe("tool-1"); expect(result[1].content[1].tool_use_id).toBe("tool-2"); expect(result[1].content[2].type).toBe("text"); }); it("handles empty messages array", () => { expect(fixClaudeToolPairing([])).toEqual([]); }); it("handles non-array input", () => { expect(fixClaudeToolPairing(null as any)).toEqual(null); expect(fixClaudeToolPairing(undefined as any)).toEqual(undefined); }); }); describe("validateAndFixClaudeToolPairing", () => { it("returns messages unchanged when no orphans", () => { const messages = [ { role: "user", content: "Hello" }, { role: "assistant", content: "Hi!" }, ]; const result = validateAndFixClaudeToolPairing(messages); expect(result).toEqual(messages); }); it("fixes orphaned tool_use with placeholder", () => { const messages = [ { role: "assistant", content: [{ type: "tool_use", id: "tool-1", name: "bash", input: {} }], }, { role: "user", content: [{ type: "text", text: "skip that" }] }, ]; const result = validateAndFixClaudeToolPairing(messages); const orphans = findOrphanedToolUseIds(result); expect(orphans.size).toBe(0); }); it("handles empty array", () => { expect(validateAndFixClaudeToolPairing([])).toEqual([]); }); }); describe("injectParameterSignatures", () => { it("injects signatures into tool descriptions", () => { const tools = [ { functionDeclarations: [ { name: "read", description: "Read a file", parameters: { type: "object", properties: { path: { type: "string", description: "File path" }, }, required: ["path"], }, }, ], }, ]; const result = injectParameterSignatures(tools); expect(result[0].functionDeclarations[0].description).toContain("STRICT PARAMETERS:"); expect(result[0].functionDeclarations[0].description).toContain("path"); expect(result[0].functionDeclarations[0].description).toContain("REQUIRED"); }); it("skips injection if STRICT PARAMETERS already present", () => { const tools = [ { functionDeclarations: [ { name: "read", description: "Read a file\n\nSTRICT PARAMETERS: path (string, REQUIRED)", parameters: { type: "object", properties: { path: { type: "string" }, }, required: ["path"], }, }, ], }, ]; const result = injectParameterSignatures(tools); const matches = result[0].functionDeclarations[0].description.match(/STRICT PARAMETERS/g); expect(matches).toHaveLength(1); }); it("skips tools without properties", () => { const tools = [ { functionDeclarations: [ { name: "empty_tool", description: "A tool with no params", parameters: { type: "object", properties: {}, }, }, ], }, ]; const result = injectParameterSignatures(tools); expect(result[0].functionDeclarations[0].description).toBe("A tool with no params"); }); it("handles missing parameters gracefully", () => { const tools = [ { functionDeclarations: [ { name: "no_params", description: "No parameters defined", }, ], }, ]; const result = injectParameterSignatures(tools); expect(result[0].functionDeclarations[0].description).toBe("No parameters defined"); }); it("returns empty array for empty input", () => { expect(injectParameterSignatures([])).toEqual([]); }); it("returns null/undefined as-is", () => { expect(injectParameterSignatures(null as any)).toBeNull(); expect(injectParameterSignatures(undefined as any)).toBeUndefined(); }); }); describe("injectToolHardeningInstruction", () => { it("injects system instruction when none exists", () => { const payload: Record = {}; injectToolHardeningInstruction(payload, "CRITICAL TOOL USAGE INSTRUCTIONS: Test"); expect(payload.systemInstruction).toBeDefined(); const instruction = payload.systemInstruction as any; expect(instruction.parts[0].text).toBe("CRITICAL TOOL USAGE INSTRUCTIONS: Test"); }); it("prepends to existing system instruction parts", () => { const payload: Record = { systemInstruction: { parts: [{ text: "Existing instruction" }], }, }; injectToolHardeningInstruction(payload, "CRITICAL TOOL USAGE INSTRUCTIONS: New"); const instruction = payload.systemInstruction as any; expect(instruction.parts).toHaveLength(2); expect(instruction.parts[0].text).toBe("CRITICAL TOOL USAGE INSTRUCTIONS: New"); expect(instruction.parts[1].text).toBe("Existing instruction"); }); it("skips injection if CRITICAL TOOL USAGE INSTRUCTIONS already present", () => { const payload: Record = { systemInstruction: { parts: [{ text: "CRITICAL TOOL USAGE INSTRUCTIONS: Already here" }], }, }; injectToolHardeningInstruction(payload, "CRITICAL TOOL USAGE INSTRUCTIONS: New"); const instruction = payload.systemInstruction as any; expect(instruction.parts).toHaveLength(1); expect(instruction.parts[0].text).toBe("CRITICAL TOOL USAGE INSTRUCTIONS: Already here"); }); it("handles string systemInstruction", () => { const payload: Record = { systemInstruction: "Existing string instruction", }; injectToolHardeningInstruction(payload, "CRITICAL TOOL USAGE INSTRUCTIONS: Test"); const instruction = payload.systemInstruction as any; expect(instruction.parts).toHaveLength(2); expect(instruction.parts[0].text).toBe("CRITICAL TOOL USAGE INSTRUCTIONS: Test"); expect(instruction.parts[1].text).toBe("Existing string instruction"); }); it("does nothing when instructionText is empty", () => { const payload: Record = {}; injectToolHardeningInstruction(payload, ""); expect(payload.systemInstruction).toBeUndefined(); }); }); describe("placeholder parameter for empty schemas", () => { it("uses _placeholder boolean instead of reason string", () => { const tools = [ { functionDeclarations: [ { name: "todoread", description: "Read todo list", parameters: { type: "object", properties: { _placeholder: { type: "boolean", description: "Placeholder. Always pass true." }, }, required: ["_placeholder"], }, }, ], }, ]; const result = injectParameterSignatures(tools); expect(result[0].functionDeclarations[0].description).toContain("STRICT PARAMETERS:"); expect(result[0].functionDeclarations[0].description).toContain("_placeholder (boolean"); }); }); describe("cleanJSONSchemaForAntigravity", () => { describe("enum merging from anyOf/oneOf", () => { it("merges anyOf with const values into enum (WebFetch format pattern)", () => { const schema = { type: "object", properties: { format: { anyOf: [ { const: "text" }, { const: "markdown" }, { const: "html" }, ], }, }, }; const result = cleanJSONSchemaForAntigravity(schema); expect(result.properties.format.enum).toEqual(["text", "markdown", "html"]); expect(result.properties.format.anyOf).toBeUndefined(); expect(result.properties.format.type).toBe("string"); }); it("merges oneOf with const values into enum", () => { const schema = { type: "object", properties: { status: { oneOf: [ { const: "pending" }, { const: "active" }, { const: "completed" }, ], }, }, }; const result = cleanJSONSchemaForAntigravity(schema); expect(result.properties.status.enum).toEqual(["pending", "active", "completed"]); expect(result.properties.status.oneOf).toBeUndefined(); }); it("merges anyOf with single-value enums into combined enum", () => { const schema = { type: "object", properties: { level: { anyOf: [ { enum: ["low"] }, { enum: ["medium"] }, { enum: ["high"] }, ], }, }, }; const result = cleanJSONSchemaForAntigravity(schema); expect(result.properties.level.enum).toEqual(["low", "medium", "high"]); }); it("merges anyOf with multi-value enums", () => { const schema = { type: "object", properties: { color: { anyOf: [ { enum: ["red", "blue"] }, { enum: ["green", "yellow"] }, ], }, }, }; const result = cleanJSONSchemaForAntigravity(schema); expect(result.properties.color.enum).toEqual(["red", "blue", "green", "yellow"]); }); it("does not merge anyOf with complex types (not enum pattern)", () => { const schema = { type: "object", properties: { data: { anyOf: [ { type: "string" }, { type: "number" }, ], }, }, }; const result = cleanJSONSchemaForAntigravity(schema); expect(result.properties.data.enum).toBeUndefined(); expect(result.properties.data.type).toBe("string"); }); it("preserves parent description when merging enum", () => { const schema = { type: "object", properties: { format: { description: "Output format for the content", anyOf: [ { const: "text" }, { const: "markdown" }, ], }, }, }; const result = cleanJSONSchemaForAntigravity(schema); expect(result.properties.format.enum).toEqual(["text", "markdown"]); expect(result.properties.format.description).toContain("Output format"); }); }); it("adds enum hints to description", () => { const schema = { type: "object", properties: { status: { type: "string", enum: ["active", "inactive", "pending"], }, }, }; const result = cleanJSONSchemaForAntigravity(schema); expect(result.properties.status.description).toContain("Allowed:"); expect(result.properties.status.description).toContain("active"); expect(result.properties.status.description).toContain("inactive"); expect(result.properties.status.description).toContain("pending"); }); it("preserves existing enum array", () => { const schema = { type: "object", properties: { level: { type: "string", enum: ["low", "medium", "high"], }, }, }; const result = cleanJSONSchemaForAntigravity(schema); expect(result.properties.level.enum).toEqual(["low", "medium", "high"]); }); }); describe("createSyntheticErrorResponse", () => { it("returns a Response with 200 OK status", async () => { const response = createSyntheticErrorResponse("Test error", "claude-sonnet"); expect(response.status).toBe(200); expect(response.headers.get("content-type")).toBe("text/event-stream"); }); it("includes error message in SSE stream content", async () => { const response = createSyntheticErrorResponse("Context too long", "claude-sonnet"); const text = await response.text(); expect(text).toContain("Context too long"); expect(text).toContain("data:"); expect(text).toContain("message_start"); expect(text).toContain("message_stop"); }); it("uses provided model in message_start event", async () => { const response = createSyntheticErrorResponse("Error", "claude-opus-4"); const text = await response.text(); expect(text).toContain("claude-opus-4"); }); it("generates valid Claude SSE event structure", async () => { const response = createSyntheticErrorResponse("Test", "test-model"); const text = await response.text(); const lines = text.split("\n").filter((l) => l.startsWith("data:")); expect(lines.length).toBeGreaterThanOrEqual(5); const events = lines.map((l) => JSON.parse(l.replace("data: ", ""))); const eventTypes = events.map((e) => e.type); expect(eventTypes).toContain("message_start"); expect(eventTypes).toContain("content_block_start"); expect(eventTypes).toContain("content_block_delta"); expect(eventTypes).toContain("content_block_stop"); expect(eventTypes).toContain("message_stop"); }); it("includes error message in content_block_delta", async () => { const response = createSyntheticErrorResponse("Something failed", "model"); const text = await response.text(); const lines = text.split("\n").filter((l) => l.startsWith("data:")); const events = lines.map((l) => JSON.parse(l.replace("data: ", ""))); const delta = events.find((e) => e.type === "content_block_delta"); expect(delta?.delta?.text).toBe("Something failed"); }); it("sets end_turn stop reason in message_delta", async () => { const response = createSyntheticErrorResponse("Error", "model"); const text = await response.text(); const lines = text.split("\n").filter((l) => l.startsWith("data:")); const events = lines.map((l) => JSON.parse(l.replace("data: ", ""))); const messageDelta = events.find((e) => e.type === "message_delta"); expect(messageDelta?.delta?.stop_reason).toBe("end_turn"); }); }); describe("extractVariantThinkingConfig", () => { it("returns undefined for undefined input", () => { expect(extractVariantThinkingConfig(undefined)).toBeUndefined(); }); it("returns undefined for empty object", () => { expect(extractVariantThinkingConfig({})).toBeUndefined(); }); it("returns undefined when google key is missing", () => { expect(extractVariantThinkingConfig({ other: {} })).toBeUndefined(); }); it("extracts thinkingLevel from Gemini 3 native format", () => { const result = extractVariantThinkingConfig({ google: { thinkingLevel: "high" }, }); expect(result).toEqual({ thinkingLevel: "high", includeThoughts: undefined }); }); it("extracts thinkingLevel with includeThoughts", () => { const result = extractVariantThinkingConfig({ google: { thinkingLevel: "medium", includeThoughts: true }, }); expect(result).toEqual({ thinkingLevel: "medium", includeThoughts: true }); }); it("extracts thinkingLevel with includeThoughts false", () => { const result = extractVariantThinkingConfig({ google: { thinkingLevel: "low", includeThoughts: false }, }); expect(result).toEqual({ thinkingLevel: "low", includeThoughts: false }); }); it("extracts thinkingBudget from budget-based format (Claude/Gemini 2.5)", () => { const result = extractVariantThinkingConfig({ google: { thinkingConfig: { thinkingBudget: 16384 } }, }); expect(result).toEqual({ thinkingBudget: 16384 }); }); it("prioritizes thinkingLevel over thinkingBudget", () => { const result = extractVariantThinkingConfig({ google: { thinkingLevel: "high", thinkingConfig: { thinkingBudget: 8192 }, }, }); expect(result).toEqual({ thinkingLevel: "high", includeThoughts: undefined }); }); it("returns undefined for invalid thinkingLevel type", () => { expect(extractVariantThinkingConfig({ google: { thinkingLevel: 123 }, })).toBeUndefined(); }); it("returns undefined for invalid thinkingBudget type", () => { expect(extractVariantThinkingConfig({ google: { thinkingConfig: { thinkingBudget: "high" } }, })).toBeUndefined(); }); it("extracts thinkingBudget from generationConfig when providerOptions is undefined", () => { const result = extractVariantThinkingConfig(undefined, { thinkingConfig: { thinkingBudget: 8192 }, }); expect(result).toEqual({ thinkingBudget: 8192 }); }); it("extracts thinkingBudget from generationConfig when providerOptions has no google key", () => { const result = extractVariantThinkingConfig({}, { thinkingConfig: { thinkingBudget: 4096 }, }); expect(result).toEqual({ thinkingBudget: 4096 }); }); it("extracts thinkingLevel from generationConfig when providerOptions is undefined", () => { const result = extractVariantThinkingConfig(undefined, { thinkingConfig: { thinkingLevel: "high", includeThoughts: true }, }); expect(result).toEqual({ thinkingLevel: "high", includeThoughts: true }); }); it("extracts thinkingLevel from generationConfig when providerOptions has no google key", () => { const result = extractVariantThinkingConfig({}, { thinkingConfig: { thinkingLevel: "low", includeThoughts: false }, }); expect(result).toEqual({ thinkingLevel: "low", includeThoughts: false }); }); it("prefers providerOptions over generationConfig", () => { const result = extractVariantThinkingConfig( { google: { thinkingConfig: { thinkingBudget: 32000 } } }, { thinkingConfig: { thinkingBudget: 8192 } }, ); expect(result).toEqual({ thinkingBudget: 32000 }); }); it("prefers providerOptions thinkingLevel over generationConfig budget", () => { const result = extractVariantThinkingConfig( { google: { thinkingLevel: "low" } }, { thinkingConfig: { thinkingBudget: 8192 } }, ); expect(result).toEqual({ thinkingLevel: "low" }); }); it("ignores generationConfig when providerOptions has googleSearch only", () => { const result = extractVariantThinkingConfig( { google: { googleSearch: { mode: "auto" } } }, { thinkingConfig: { thinkingBudget: 8192 } }, ); expect(result).toEqual({ googleSearch: { mode: "auto" }, thinkingBudget: 8192, }); }); it("does not overwrite thinkingBudget: 0 from providerOptions with generationConfig fallback", () => { const result = extractVariantThinkingConfig( { google: { thinkingConfig: { thinkingBudget: 0 } } }, { thinkingConfig: { thinkingBudget: 8192 } }, ); expect(result).toEqual({ thinkingBudget: 0 }); }); it("returns undefined when both sources have no thinking config", () => { expect(extractVariantThinkingConfig(undefined, {})).toBeUndefined(); expect(extractVariantThinkingConfig(undefined, { temperature: 0.5 })).toBeUndefined(); }); }); describe("deduplicateThinkingText", () => { function createTestBuffer() { return createThoughtBuffer(); } it("returns non-object input unchanged", () => { const buffer = createTestBuffer(); expect(deduplicateThinkingText(null, buffer)).toBeNull(); expect(deduplicateThinkingText(undefined, buffer)).toBeUndefined(); expect(deduplicateThinkingText("string", buffer)).toBe("string"); }); it("extracts delta from accumulated Gemini thinking text", () => { const buffer = createTestBuffer(); const chunk1 = { candidates: [{ content: { parts: [{ thought: true, text: "Hello " }], }, }], }; // eslint-disable-next-line @typescript-eslint/no-explicit-any const result1 = deduplicateThinkingText(chunk1, buffer) as any; expect(result1.candidates[0].content.parts[0].text).toBe("Hello "); const chunk2 = { candidates: [{ content: { parts: [{ thought: true, text: "Hello world" }], }, }], }; // eslint-disable-next-line @typescript-eslint/no-explicit-any const result2 = deduplicateThinkingText(chunk2, buffer) as any; expect(result2.candidates[0].content.parts[0].text).toBe("world"); }); it("filters out empty delta parts", () => { const buffer = createTestBuffer(); const chunk1 = { candidates: [{ content: { parts: [{ thought: true, text: "Complete thought" }], }, }], }; deduplicateThinkingText(chunk1, buffer); const chunk2 = { candidates: [{ content: { parts: [ { thought: true, text: "Complete thought" }, { text: "Regular text" }, ], }, }], }; // eslint-disable-next-line @typescript-eslint/no-explicit-any const result2 = deduplicateThinkingText(chunk2, buffer) as any; expect(result2.candidates[0].content.parts).toHaveLength(1); expect(result2.candidates[0].content.parts[0].text).toBe("Regular text"); }); it("extracts delta from accumulated Claude thinking blocks", () => { const buffer = createTestBuffer(); const chunk1 = { content: [{ type: "thinking", thinking: "First " }], }; // eslint-disable-next-line @typescript-eslint/no-explicit-any const result1 = deduplicateThinkingText(chunk1, buffer) as any; expect(result1.content[0].thinking).toBe("First "); const chunk2 = { content: [{ type: "thinking", thinking: "First part" }], }; // eslint-disable-next-line @typescript-eslint/no-explicit-any const result2 = deduplicateThinkingText(chunk2, buffer) as any; expect(result2.content[0].thinking).toBe("part"); }); it("handles new thinking content that does not start with sent text", () => { const buffer = createTestBuffer(); const chunk1 = { candidates: [{ content: { parts: [{ thought: true, text: "Old thought" }], }, }], }; deduplicateThinkingText(chunk1, buffer); const chunk2 = { candidates: [{ content: { parts: [{ thought: true, text: "New thought" }], }, }], }; // eslint-disable-next-line @typescript-eslint/no-explicit-any const result2 = deduplicateThinkingText(chunk2, buffer) as any; expect(result2.candidates[0].content.parts[0].text).toBe("New thought"); }); it("preserves non-thinking parts unchanged", () => { const buffer = createTestBuffer(); const chunk = { candidates: [{ content: { parts: [ { thought: true, text: "Thinking" }, { text: "Regular text" }, { functionCall: { name: "test" } }, ], }, }], }; // eslint-disable-next-line @typescript-eslint/no-explicit-any const result = deduplicateThinkingText(chunk, buffer) as any; expect(result.candidates[0].content.parts[1].text).toBe("Regular text"); expect(result.candidates[0].content.parts[2].functionCall.name).toBe("test"); }); }); describe("recursivelyParseJsonStrings", () => { it("parses JSON strings in non-protected keys", () => { const input = { metadata: '{"key": "value"}' }; const result = recursivelyParseJsonStrings(input); expect(result).toEqual({ metadata: { key: "value" } }); }); it("preserves oldString/newString even when they contain valid JSON", () => { const input = { oldString: '{"name": "test"}', newString: '{"name": "updated"}', }; const result = recursivelyParseJsonStrings(input); expect(result).toEqual({ oldString: '{"name": "test"}', newString: '{"name": "updated"}', }); }); it("preserves content parameter even when it contains valid JSON", () => { const input = { content: '{"dependencies": {"lodash": "^4.0.0"}}', filePath: "/path/to/package.json", }; const result = recursivelyParseJsonStrings(input); expect(result).toEqual({ content: '{"dependencies": {"lodash": "^4.0.0"}}', filePath: "/path/to/package.json", }); }); it("parses JSON in non-protected keys", () => { const input = { metadata: '{"version": 1}', oldString: '{"should": "stay"}', }; const result = recursivelyParseJsonStrings(input); expect(result).toEqual({ metadata: { version: 1 }, oldString: '{"should": "stay"}', }); }); it("handles nested objects with protected keys", () => { const input = { tool: { name: "edit", args: { oldString: '["item1", "item2"]', newString: '["item1", "item2", "item3"]', }, }, }; const result = recursivelyParseJsonStrings(input); expect(result).toEqual({ tool: { name: "edit", args: { oldString: '["item1", "item2"]', newString: '["item1", "item2", "item3"]', }, }, }); }); }); ================================================ FILE: src/plugin/request-helpers.ts ================================================ import { getKeepThinking } from "./config"; import { createLogger } from "./logger"; import { cacheSignature } from "./cache"; import { EMPTY_SCHEMA_PLACEHOLDER_NAME, EMPTY_SCHEMA_PLACEHOLDER_DESCRIPTION, SKIP_THOUGHT_SIGNATURE, } from "../constants"; import { processImageData } from "./image-saver"; import type { GoogleSearchConfig } from "./transform/types"; const log = createLogger("request-helpers"); const ANTIGRAVITY_PREVIEW_LINK = "https://goo.gle/enable-preview-features"; // TODO: Update to Antigravity link if available // ============================================================================ // JSON SCHEMA CLEANING FOR ANTIGRAVITY API // Ported from CLIProxyAPI's CleanJSONSchemaForAntigravity (gemini_schema.go) // ============================================================================ /** * Unsupported constraint keywords that should be moved to description hints. * Claude/Gemini reject these in VALIDATED mode. */ const UNSUPPORTED_CONSTRAINTS = [ "minLength", "maxLength", "exclusiveMinimum", "exclusiveMaximum", "pattern", "minItems", "maxItems", "format", "default", "examples", ] as const; /** * Keywords that should be removed after hint extraction. */ const UNSUPPORTED_KEYWORDS = [ ...UNSUPPORTED_CONSTRAINTS, "$schema", "$defs", "definitions", "const", "$ref", "additionalProperties", "propertyNames", "title", "$id", "$comment", ] as const; /** * Appends a hint to a schema's description field. */ function appendDescriptionHint(schema: any, hint: string): any { if (!schema || typeof schema !== "object") { return schema; } const existing = typeof schema.description === "string" ? schema.description : ""; const newDescription = existing ? `${existing} (${hint})` : hint; return { ...schema, description: newDescription }; } /** * Phase 1a: Converts $ref to description hints. * $ref: "#/$defs/Foo" → { type: "object", description: "See: Foo" } */ function convertRefsToHints(schema: any): any { if (!schema || typeof schema !== "object") { return schema; } if (Array.isArray(schema)) { return schema.map(item => convertRefsToHints(item)); } // If this object has $ref, replace it with a hint if (typeof schema.$ref === "string") { const refVal = schema.$ref; const defName = refVal.includes("/") ? refVal.split("/").pop() : refVal; const hint = `See: ${defName}`; const existingDesc = typeof schema.description === "string" ? schema.description : ""; const newDescription = existingDesc ? `${existingDesc} (${hint})` : hint; return { type: "object", description: newDescription }; } // Recursively process all properties const result: any = {}; for (const [key, value] of Object.entries(schema)) { result[key] = convertRefsToHints(value); } return result; } /** * Phase 1b: Converts const to enum. * { const: "foo" } → { enum: ["foo"] } */ function convertConstToEnum(schema: any): any { if (!schema || typeof schema !== "object") { return schema; } if (Array.isArray(schema)) { return schema.map(item => convertConstToEnum(item)); } const result: any = {}; for (const [key, value] of Object.entries(schema)) { if (key === "const" && !schema.enum) { result.enum = [value]; } else { result[key] = convertConstToEnum(value); } } return result; } /** * Phase 1c: Adds enum hints to description. * { enum: ["a", "b", "c"] } → adds "(Allowed: a, b, c)" to description */ function addEnumHints(schema: any): any { if (!schema || typeof schema !== "object") { return schema; } if (Array.isArray(schema)) { return schema.map(item => addEnumHints(item)); } let result: any = { ...schema }; // Add enum hint if enum has 2-10 items if (Array.isArray(result.enum) && result.enum.length > 1 && result.enum.length <= 10) { const vals = result.enum.map((v: any) => String(v)).join(", "); result = appendDescriptionHint(result, `Allowed: ${vals}`); } // Recursively process nested objects for (const [key, value] of Object.entries(result)) { if (key !== "enum" && typeof value === "object" && value !== null) { result[key] = addEnumHints(value); } } return result; } /** * Phase 1d: Adds additionalProperties hints. * { additionalProperties: false } → adds "(No extra properties allowed)" to description */ function addAdditionalPropertiesHints(schema: any): any { if (!schema || typeof schema !== "object") { return schema; } if (Array.isArray(schema)) { return schema.map(item => addAdditionalPropertiesHints(item)); } let result: any = { ...schema }; if (result.additionalProperties === false) { result = appendDescriptionHint(result, "No extra properties allowed"); } // Recursively process nested objects for (const [key, value] of Object.entries(result)) { if (key !== "additionalProperties" && typeof value === "object" && value !== null) { result[key] = addAdditionalPropertiesHints(value); } } return result; } /** * Phase 1e: Moves unsupported constraints to description hints. * { minLength: 1, maxLength: 100 } → adds "(minLength: 1) (maxLength: 100)" to description */ function moveConstraintsToDescription(schema: any): any { if (!schema || typeof schema !== "object") { return schema; } if (Array.isArray(schema)) { return schema.map(item => moveConstraintsToDescription(item)); } let result: any = { ...schema }; // Move constraint values to description for (const constraint of UNSUPPORTED_CONSTRAINTS) { if (result[constraint] !== undefined && typeof result[constraint] !== "object") { result = appendDescriptionHint(result, `${constraint}: ${result[constraint]}`); } } // Recursively process nested objects for (const [key, value] of Object.entries(result)) { if (typeof value === "object" && value !== null) { result[key] = moveConstraintsToDescription(value); } } return result; } /** * Phase 2a: Merges allOf schemas into a single object. * { allOf: [{ properties: { a: ... } }, { properties: { b: ... } }] } * → { properties: { a: ..., b: ... } } */ function mergeAllOf(schema: any): any { if (!schema || typeof schema !== "object") { return schema; } if (Array.isArray(schema)) { return schema.map(item => mergeAllOf(item)); } let result: any = { ...schema }; // If this object has allOf, merge its contents if (Array.isArray(result.allOf)) { const merged: any = {}; const mergedRequired: string[] = []; for (const item of result.allOf) { if (!item || typeof item !== "object") continue; // Merge properties if (item.properties && typeof item.properties === "object") { merged.properties = { ...merged.properties, ...item.properties }; } // Merge required arrays if (Array.isArray(item.required)) { for (const req of item.required) { if (!mergedRequired.includes(req)) { mergedRequired.push(req); } } } // Copy other fields from allOf items for (const [key, value] of Object.entries(item)) { if (key !== "properties" && key !== "required" && merged[key] === undefined) { merged[key] = value; } } } // Apply merged content to result if (merged.properties) { result.properties = { ...result.properties, ...merged.properties }; } if (mergedRequired.length > 0) { const existingRequired = Array.isArray(result.required) ? result.required : []; result.required = Array.from(new Set([...existingRequired, ...mergedRequired])); } // Copy other merged fields for (const [key, value] of Object.entries(merged)) { if (key !== "properties" && key !== "required" && result[key] === undefined) { result[key] = value; } } delete result.allOf; } // Recursively process nested objects for (const [key, value] of Object.entries(result)) { if (typeof value === "object" && value !== null) { result[key] = mergeAllOf(value); } } return result; } /** * Scores a schema option for selection in anyOf/oneOf flattening. * Higher score = more preferred. */ function scoreSchemaOption(schema: any): { score: number; typeName: string } { if (!schema || typeof schema !== "object") { return { score: 0, typeName: "unknown" }; } const type = schema.type; // Object or has properties = highest priority if (type === "object" || schema.properties) { return { score: 3, typeName: "object" }; } // Array or has items = second priority if (type === "array" || schema.items) { return { score: 2, typeName: "array" }; } // Any other non-null type if (type && type !== "null") { return { score: 1, typeName: type }; } // Null or no type return { score: 0, typeName: type || "null" }; } /** * Checks if an anyOf/oneOf array represents enum choices. * Returns the merged enum values if so, otherwise null. * * Handles patterns like: * - anyOf: [{ const: "a" }, { const: "b" }] * - anyOf: [{ enum: ["a"] }, { enum: ["b"] }] * - anyOf: [{ type: "string", const: "a" }, { type: "string", const: "b" }] */ function tryMergeEnumFromUnion(options: any[]): string[] | null { if (!Array.isArray(options) || options.length === 0) { return null; } const enumValues: string[] = []; for (const option of options) { if (!option || typeof option !== "object") { return null; } // Check for const value if (option.const !== undefined) { enumValues.push(String(option.const)); continue; } // Check for single-value enum if (Array.isArray(option.enum) && option.enum.length === 1) { enumValues.push(String(option.enum[0])); continue; } // Check for multi-value enum (merge all values) if (Array.isArray(option.enum) && option.enum.length > 0) { for (const val of option.enum) { enumValues.push(String(val)); } continue; } // If option has complex structure (properties, items, etc.), it's not a simple enum if (option.properties || option.items || option.anyOf || option.oneOf || option.allOf) { return null; } // If option has only type (no const/enum), it's not an enum pattern if (option.type && !option.const && !option.enum) { return null; } } // Only return if we found actual enum values return enumValues.length > 0 ? enumValues : null; } /** * Phase 2b: Flattens anyOf/oneOf to the best option with type hints. * { anyOf: [{ type: "string" }, { type: "number" }] } * → { type: "string", description: "(Accepts: string | number)" } * * Special handling for enum patterns: * { anyOf: [{ const: "a" }, { const: "b" }] } * → { type: "string", enum: ["a", "b"] } */ function flattenAnyOfOneOf(schema: any): any { if (!schema || typeof schema !== "object") { return schema; } if (Array.isArray(schema)) { return schema.map(item => flattenAnyOfOneOf(item)); } let result: any = { ...schema }; // Process anyOf or oneOf for (const unionKey of ["anyOf", "oneOf"] as const) { if (Array.isArray(result[unionKey]) && result[unionKey].length > 0) { const options = result[unionKey]; const parentDesc = typeof result.description === "string" ? result.description : ""; // First, check if this is an enum pattern (anyOf with const/enum values) // This is crucial for tools like WebFetch where format: anyOf[{const:"text"},{const:"markdown"},{const:"html"}] const mergedEnum = tryMergeEnumFromUnion(options); if (mergedEnum !== null) { // This is an enum pattern - merge all values into a single enum const { [unionKey]: _, ...rest } = result; result = { ...rest, type: "string", enum: mergedEnum, }; // Preserve parent description if (parentDesc) { result.description = parentDesc; } continue; } // Not an enum pattern - use standard flattening logic // Score each option and find the best let bestIdx = 0; let bestScore = -1; const allTypes: string[] = []; for (let i = 0; i < options.length; i++) { const { score, typeName } = scoreSchemaOption(options[i]); if (typeName) { allTypes.push(typeName); } if (score > bestScore) { bestScore = score; bestIdx = i; } } // Select the best option and flatten it recursively let selected = flattenAnyOfOneOf(options[bestIdx]) || { type: "string" }; // Preserve parent description if (parentDesc) { const childDesc = typeof selected.description === "string" ? selected.description : ""; if (childDesc && childDesc !== parentDesc) { selected = { ...selected, description: `${parentDesc} (${childDesc})` }; } else if (!childDesc) { selected = { ...selected, description: parentDesc }; } } if (allTypes.length > 1) { const uniqueTypes = Array.from(new Set(allTypes)); const hint = `Accepts: ${uniqueTypes.join(" | ")}`; selected = appendDescriptionHint(selected, hint); } // Replace result with selected schema, preserving other fields const { [unionKey]: _, description: __, ...rest } = result; result = { ...rest, ...selected }; } } // Recursively process nested objects for (const [key, value] of Object.entries(result)) { if (typeof value === "object" && value !== null) { result[key] = flattenAnyOfOneOf(value); } } return result; } /** * Phase 2c: Flattens type arrays to single type with nullable hint. * { type: ["string", "null"] } → { type: "string", description: "(nullable)" } */ function flattenTypeArrays(schema: any, nullableFields?: Map, currentPath?: string): any { if (!schema || typeof schema !== "object") { return schema; } if (Array.isArray(schema)) { return schema.map((item, idx) => flattenTypeArrays(item, nullableFields, `${currentPath || ""}[${idx}]`)); } let result: any = { ...schema }; const localNullableFields = nullableFields || new Map(); // Handle type array if (Array.isArray(result.type)) { const types = result.type as string[]; 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(" | ")}`); } // Add nullable hint if (hasNull) { result = appendDescriptionHint(result, "nullable"); } } // Recursively process properties if (result.properties && typeof result.properties === "object") { const newProps: any = {}; for (const [propKey, propValue] of Object.entries(result.properties)) { const propPath = currentPath ? `${currentPath}.properties.${propKey}` : `properties.${propKey}`; const processed = flattenTypeArrays(propValue, localNullableFields, propPath); newProps[propKey] = processed; // Track nullable fields for required array cleanup if (processed && typeof processed === "object" && typeof processed.description === "string" && processed.description.includes("nullable")) { const objectPath = currentPath || ""; const existing = localNullableFields.get(objectPath) || []; existing.push(propKey); localNullableFields.set(objectPath, existing); } } result.properties = newProps; } // Remove nullable fields from required array if (Array.isArray(result.required) && !nullableFields) { // Only at root level, filter out nullable fields const nullableAtRoot = localNullableFields.get("") || []; if (nullableAtRoot.length > 0) { result.required = result.required.filter((r: string) => !nullableAtRoot.includes(r)); if (result.required.length === 0) { delete result.required; } } } // Recursively process other nested objects for (const [key, value] of Object.entries(result)) { if (key !== "properties" && typeof value === "object" && value !== null) { result[key] = flattenTypeArrays(value, localNullableFields, `${currentPath || ""}.${key}`); } } return result; } /** * Phase 3: Removes unsupported keywords after hints have been extracted. * @param insideProperties - When true, keys are property NAMES (preserve); when false, keys are JSON Schema keywords (filter). */ function removeUnsupportedKeywords(schema: any, insideProperties: boolean = false): any { if (!schema || typeof schema !== "object") { return schema; } if (Array.isArray(schema)) { return schema.map(item => removeUnsupportedKeywords(item, false)); } const result: any = {}; for (const [key, value] of Object.entries(schema)) { if (!insideProperties && (UNSUPPORTED_KEYWORDS as readonly string[]).includes(key)) { continue; } if (typeof value === "object" && value !== null) { if (key === "properties") { const propertiesResult: any = {}; for (const [propName, propSchema] of Object.entries(value as object)) { propertiesResult[propName] = removeUnsupportedKeywords(propSchema, false); } result[key] = propertiesResult; } else { result[key] = removeUnsupportedKeywords(value, false); } } else { result[key] = value; } } return result; } /** * Phase 3b: Cleans up required fields - removes entries that don't exist in properties. */ function cleanupRequiredFields(schema: any): any { if (!schema || typeof schema !== "object") { return schema; } if (Array.isArray(schema)) { return schema.map(item => cleanupRequiredFields(item)); } let result: any = { ...schema }; // Clean up required array if properties exist if (Array.isArray(result.required) && result.properties && typeof result.properties === "object") { const validRequired = result.required.filter((req: string) => Object.prototype.hasOwnProperty.call(result.properties, req) ); if (validRequired.length === 0) { delete result.required; } else if (validRequired.length !== result.required.length) { result.required = validRequired; } } // Recursively process nested objects for (const [key, value] of Object.entries(result)) { if (typeof value === "object" && value !== null) { result[key] = cleanupRequiredFields(value); } } return result; } /** * Phase 4: Adds placeholder property for empty object schemas. * Claude VALIDATED mode requires at least one property. */ function addEmptySchemaPlaceholder(schema: any): any { if (!schema || typeof schema !== "object") { return schema; } if (Array.isArray(schema)) { return schema.map(item => addEmptySchemaPlaceholder(item)); } let result: any = { ...schema }; // Check if this is an empty object schema const isObjectType = result.type === "object"; if (isObjectType) { const hasProperties = result.properties && typeof result.properties === "object" && Object.keys(result.properties).length > 0; if (!hasProperties) { result.properties = { [EMPTY_SCHEMA_PLACEHOLDER_NAME]: { type: "boolean", description: EMPTY_SCHEMA_PLACEHOLDER_DESCRIPTION, }, }; result.required = [EMPTY_SCHEMA_PLACEHOLDER_NAME]; } } // Recursively process nested objects for (const [key, value] of Object.entries(result)) { if (typeof value === "object" && value !== null) { result[key] = addEmptySchemaPlaceholder(value); } } return result; } /** * Cleans a JSON schema for Antigravity API compatibility. * Transforms unsupported features into description hints while preserving semantic information. * * Ported from CLIProxyAPI's CleanJSONSchemaForAntigravity (gemini_schema.go) */ export function cleanJSONSchemaForAntigravity(schema: any): any { if (!schema || typeof schema !== "object") { return schema; } let result = schema; // Phase 1: Convert and add hints result = convertRefsToHints(result); result = convertConstToEnum(result); result = addEnumHints(result); result = addAdditionalPropertiesHints(result); result = moveConstraintsToDescription(result); // Phase 2: Flatten complex structures result = mergeAllOf(result); result = flattenAnyOfOneOf(result); result = flattenTypeArrays(result); // Phase 3: Cleanup result = removeUnsupportedKeywords(result); result = cleanupRequiredFields(result); // Phase 4: Add placeholder for empty object schemas result = addEmptySchemaPlaceholder(result); return result; } // ============================================================================ // END JSON SCHEMA CLEANING // ============================================================================ export interface AntigravityApiError { code?: number; message?: string; status?: string; [key: string]: unknown; } /** * Minimal representation of Antigravity API responses we touch. */ export interface AntigravityApiBody { response?: unknown; error?: AntigravityApiError; [key: string]: unknown; } /** * Usage metadata exposed by Antigravity responses. Fields are optional to reflect partial payloads. */ export interface AntigravityUsageMetadata { totalTokenCount?: number; promptTokenCount?: number; candidatesTokenCount?: number; cachedContentTokenCount?: number; thoughtsTokenCount?: number; } /** * Normalized thinking configuration accepted by Antigravity. */ export interface ThinkingConfig { thinkingBudget?: number; includeThoughts?: boolean; } /** * Default token budget for thinking/reasoning. 16000 tokens provides sufficient * space for complex reasoning while staying within typical model limits. */ export const DEFAULT_THINKING_BUDGET = 16000; /** * Checks if a model name indicates thinking/reasoning capability. * Models with "thinking", "gemini-3", or "opus" in their name support extended thinking. */ export function isThinkingCapableModel(modelName: string): boolean { const lowerModel = modelName.toLowerCase(); return lowerModel.includes("thinking") || lowerModel.includes("gemini-3") || lowerModel.includes("opus"); } /** * Extracts thinking configuration from various possible request locations. * Supports both Gemini-style thinkingConfig and Anthropic-style thinking options. */ export function extractThinkingConfig( requestPayload: Record, rawGenerationConfig: Record | undefined, extraBody: Record | undefined, ): ThinkingConfig | undefined { const thinkingConfig = rawGenerationConfig?.thinkingConfig ?? extraBody?.thinkingConfig ?? requestPayload.thinkingConfig; if (thinkingConfig && typeof thinkingConfig === "object") { const config = thinkingConfig as Record; return { includeThoughts: Boolean(config.includeThoughts), thinkingBudget: typeof config.thinkingBudget === "number" ? config.thinkingBudget : DEFAULT_THINKING_BUDGET, }; } // Convert Anthropic-style "thinking" option: { type: "enabled", budgetTokens: N } const anthropicThinking = extraBody?.thinking ?? requestPayload.thinking; if (anthropicThinking && typeof anthropicThinking === "object") { const thinking = anthropicThinking as Record; if (thinking.type === "enabled" || thinking.budgetTokens) { return { includeThoughts: true, thinkingBudget: typeof thinking.budgetTokens === "number" ? thinking.budgetTokens : DEFAULT_THINKING_BUDGET, }; } } return undefined; } /** * Variant thinking config extracted from OpenCode's providerOptions. */ export interface VariantThinkingConfig { /** Gemini 3 native thinking level (low/medium/high) */ thinkingLevel?: string; /** Numeric thinking budget for Claude and Gemini 2.5 */ thinkingBudget?: number; /** Whether to include thoughts in output */ includeThoughts?: boolean; /** Google Search configuration */ googleSearch?: GoogleSearchConfig; } /** * Extracts variant thinking config from OpenCode's providerOptions. * * All Antigravity models route through the Google provider, so we only check * providerOptions.google. Supports two formats: * * 1. Gemini 3 native: { google: { thinkingLevel: "high", includeThoughts: true } } * 2. Budget-based (Claude/Gemini 2.5): { google: { thinkingConfig: { thinkingBudget: 32000 } } } * * When providerOptions is missing or has no thinking config (common with OpenCode * model variants), falls back to extracting from generationConfig directly: * 3. generationConfig fallback: { thinkingConfig: { thinkingBudget: 8192 } } */ export function extractVariantThinkingConfig( providerOptions: Record | undefined, generationConfig?: Record | undefined ): VariantThinkingConfig | undefined { const result: VariantThinkingConfig = {}; // Primary path: extract from providerOptions.google const google = (providerOptions?.google) as Record | undefined; if (google) { // Gemini 3 native format: { google: { thinkingLevel: "high", includeThoughts: true } } // thinkingLevel takes priority over thinkingBudget - they are mutually exclusive if (typeof google.thinkingLevel === "string") { result.thinkingLevel = google.thinkingLevel; result.includeThoughts = typeof google.includeThoughts === "boolean" ? google.includeThoughts : undefined; } else if (google.thinkingConfig && typeof google.thinkingConfig === "object") { // Budget-based format (Claude/Gemini 2.5): { google: { thinkingConfig: { thinkingBudget } } } // Only used when thinkingLevel is not present const tc = google.thinkingConfig as Record; if (typeof tc.thinkingBudget === "number") { result.thinkingBudget = tc.thinkingBudget; } } // Extract Google Search config if (google.googleSearch && typeof google.googleSearch === "object") { const search = google.googleSearch as Record; result.googleSearch = { mode: search.mode === 'auto' || search.mode === 'off' ? search.mode : undefined, threshold: typeof search.threshold === 'number' ? search.threshold : undefined, }; } } // Fallback: OpenCode may pass thinking config in generationConfig // instead of providerOptions (common when using model variants) if (result.thinkingBudget === undefined && !result.thinkingLevel && generationConfig) { if (generationConfig.thinkingConfig && typeof generationConfig.thinkingConfig === "object") { const tc = generationConfig.thinkingConfig as Record; if (typeof tc.thinkingLevel === "string") { // Gemini 3 native format sent via generationConfig result.thinkingLevel = tc.thinkingLevel; result.includeThoughts = typeof tc.includeThoughts === "boolean" ? tc.includeThoughts : undefined; } else if (typeof tc.thinkingBudget === "number") { result.thinkingBudget = tc.thinkingBudget; } } } return Object.keys(result).length > 0 ? result : undefined; } /** * Determines the final thinking configuration based on model capabilities and user settings. * For Claude thinking models, we keep thinking enabled even in multi-turn conversations. * The filterUnsignedThinkingBlocks function will handle signature validation/restoration. */ export function resolveThinkingConfig( userConfig: ThinkingConfig | undefined, isThinkingModel: boolean, _isClaudeModel: boolean, _hasAssistantHistory: boolean, ): ThinkingConfig | undefined { // For thinking-capable models (including Claude thinking models), enable thinking by default // The signature validation/restoration is handled by filterUnsignedThinkingBlocks if (isThinkingModel && !userConfig) { return { includeThoughts: true, thinkingBudget: DEFAULT_THINKING_BUDGET }; } return userConfig; } /** * Checks if a part is a thinking/reasoning block (Anthropic or Gemini style). */ function isThinkingPart(part: Record): boolean { return part.type === "thinking" || part.type === "redacted_thinking" || part.type === "reasoning" || part.thinking !== undefined || part.thought === true; } /** * Checks if a part has a signature field (thinking block signature). * Used to detect foreign thinking blocks that might have unknown type values. */ function hasSignatureField(part: Record): boolean { return part.signature !== undefined || part.thoughtSignature !== undefined; } /** * Checks if a part is a tool block (tool_use or tool_result). * Tool blocks must never be filtered - they're required for tool call/result pairing. * Handles multiple formats: * - Anthropic: { type: "tool_use" }, { type: "tool_result", tool_use_id } * - Nested: { tool_result: { tool_use_id } }, { tool_use: { id } } * - Gemini: { functionCall }, { functionResponse } */ function isToolBlock(part: Record): boolean { return part.type === "tool_use" || part.type === "tool_result" || part.tool_use_id !== undefined || part.tool_call_id !== undefined || part.tool_result !== undefined || part.tool_use !== undefined || part.toolUse !== undefined || part.functionCall !== undefined || part.functionResponse !== undefined; } /** * Unconditionally strips ALL thinking/reasoning blocks from a content array. * Used for Claude models to avoid signature validation errors entirely. * Claude will generate fresh thinking for each turn. */ function stripAllThinkingBlocks(contentArray: any[]): any[] { return contentArray.filter(item => { if (!item || typeof item !== "object") return true; if (isToolBlock(item)) return true; if (isThinkingPart(item)) return false; if (hasSignatureField(item)) return false; return true; }); } /** * Removes trailing thinking blocks from a content array. * Claude API requires that assistant messages don't end with thinking blocks. * Only removes unsigned thinking blocks; preserves those with valid signatures. */ function removeTrailingThinkingBlocks( contentArray: any[], sessionId?: string, getCachedSignatureFn?: (sessionId: string, text: string) => string | undefined, ): any[] { const result = [...contentArray]; while (result.length > 0 && isThinkingPart(result[result.length - 1])) { const part = result[result.length - 1]; const isValid = sessionId && getCachedSignatureFn ? isOurCachedSignature(part as Record, sessionId, getCachedSignatureFn) : hasValidSignature(part as Record); if (isValid) { break; } result.pop(); } return result; } /** * Checks if a thinking part has a valid signature. * A valid signature is a non-empty string with at least 50 characters. */ function hasValidSignature(part: Record): boolean { const signature = part.thought === true ? part.thoughtSignature : part.signature; return typeof signature === "string" && signature.length >= 50; } /** * Gets the signature from a thinking part, if present. */ function getSignature(part: Record): string | undefined { const signature = part.thought === true ? part.thoughtSignature : part.signature; return typeof signature === "string" ? signature : undefined; } /** * Checks if a thinking part's signature was generated by our plugin (exists in our cache). * This prevents accepting signatures from other providers (e.g., direct Anthropic API, OpenAI) * which would cause "Invalid signature" errors when sent to Antigravity Claude. */ function isOurCachedSignature( part: Record, sessionId: string | undefined, getCachedSignatureFn: ((sessionId: string, text: string) => string | undefined) | undefined, ): boolean { if (!sessionId || !getCachedSignatureFn) { return false; } const text = getThinkingText(part); if (!text) { return false; } const partSignature = getSignature(part); if (!partSignature) { return false; } const cachedSignature = getCachedSignatureFn(sessionId, text); return cachedSignature === partSignature; } /** * Gets the text content from a thinking part. */ function getThinkingText(part: Record): string { if (typeof part.text === "string") return part.text; if (typeof part.thinking === "string") return part.thinking; if (part.text && typeof part.text === "object") { const maybeText = (part.text as any).text; if (typeof maybeText === "string") return maybeText; } if (part.thinking && typeof part.thinking === "object") { const maybeText = (part.thinking as any).text ?? (part.thinking as any).thinking; if (typeof maybeText === "string") return maybeText; } return ""; } /** * Recursively strips cache_control and providerOptions from any object. * These fields can be injected by SDKs, but Claude rejects them inside thinking blocks. */ function stripCacheControlRecursively(obj: unknown): unknown { if (obj === null || obj === undefined) return obj; if (typeof obj !== "object") return obj; if (Array.isArray(obj)) return obj.map(item => stripCacheControlRecursively(item)); const result: Record = {}; for (const [key, value] of Object.entries(obj as Record)) { if (key === "cache_control" || key === "providerOptions") continue; result[key] = stripCacheControlRecursively(value); } return result; } /** * Sanitizes a thinking part by keeping only the allowed fields. * In particular, ensures `thinking` is a string (not an object with cache_control). * Returns null if the thinking block has no valid content. */ function sanitizeThinkingPart(part: Record): Record | null { // Gemini-style thought blocks: { thought: true, text, thoughtSignature } if (part.thought === true) { let textContent: unknown = part.text; if (typeof textContent === "object" && textContent !== null) { const maybeText = (textContent as any).text; textContent = typeof maybeText === "string" ? maybeText : undefined; } const hasContent = typeof textContent === "string" && textContent.trim().length > 0; if (!hasContent && !part.thoughtSignature) { return null; } const sanitized: Record = { thought: true }; if (textContent !== undefined) sanitized.text = textContent; if (part.thoughtSignature !== undefined) sanitized.thoughtSignature = part.thoughtSignature; return sanitized; } // Anthropic-style thinking/redacted_thinking blocks: { type: "thinking"|"redacted_thinking", thinking, signature } if (part.type === "thinking" || part.type === "redacted_thinking" || part.thinking !== undefined) { let thinkingContent: unknown = part.thinking ?? part.text; if (thinkingContent !== undefined && typeof thinkingContent === "object" && thinkingContent !== null) { const maybeText = (thinkingContent as any).text ?? (thinkingContent as any).thinking; thinkingContent = typeof maybeText === "string" ? maybeText : undefined; } const hasContent = typeof thinkingContent === "string" && thinkingContent.trim().length > 0; if (!hasContent && !part.signature) { return null; } const sanitized: Record = { type: part.type === "redacted_thinking" ? "redacted_thinking" : "thinking" }; if (thinkingContent !== undefined) sanitized.thinking = thinkingContent; if (part.signature !== undefined) sanitized.signature = part.signature; return sanitized; } // Reasoning blocks (OpenCode format): { type: "reasoning", text, signature } if (part.type === "reasoning") { let textContent: unknown = part.text; if (typeof textContent === "object" && textContent !== null) { const maybeText = (textContent as any).text; textContent = typeof maybeText === "string" ? maybeText : undefined; } const hasContent = typeof textContent === "string" && textContent.trim().length > 0; if (!hasContent && !part.signature) { return null; } const sanitized: Record = { type: "reasoning" }; if (textContent !== undefined) sanitized.text = textContent; if (part.signature !== undefined) sanitized.signature = part.signature; return sanitized; } // Fallback: strip cache_control recursively. return stripCacheControlRecursively(part) as Record; } function findLastAssistantIndex(contents: any[], roleValue: "model" | "assistant"): number { for (let i = contents.length - 1; i >= 0; i--) { const content = contents[i]; if (content && typeof content === "object" && content.role === roleValue) { return i; } } return -1; } function filterContentArray( contentArray: any[], sessionId?: string, getCachedSignatureFn?: (sessionId: string, text: string) => string | undefined, isClaudeModel?: boolean, isLastAssistantMessage: boolean = false, ): any[] { // For Claude models, strip thinking blocks by default for reliability // User can opt-in to keep thinking via config: { "keep_thinking": true } if (isClaudeModel && !getKeepThinking()) { return stripAllThinkingBlocks(contentArray); } const filtered: any[] = []; for (const item of contentArray) { if (!item || typeof item !== "object") { filtered.push(item); continue; } if (isToolBlock(item)) { if (!isClaudeModel) { filtered.push(item); continue; } const sanitizedToolBlock = { ...(item as Record) }; delete (sanitizedToolBlock as any).signature; delete (sanitizedToolBlock as any).thoughtSignature; delete (sanitizedToolBlock as any).thought_signature; delete (sanitizedToolBlock as any).thought; filtered.push(sanitizedToolBlock); continue; } const isThinking = isThinkingPart(item); const hasSignature = hasSignatureField(item); if (!isThinking && !hasSignature) { filtered.push(item); continue; } if (isClaudeModel && (isThinking || hasSignature)) { const thinkingText = getThinkingText(item) || ""; const sentinelPart = { type: item.type === "redacted_thinking" ? "redacted_thinking" : "thinking", thinking: thinkingText, signature: SKIP_THOUGHT_SIGNATURE, }; filtered.push(sentinelPart); continue; } // For the LAST assistant message with thinking blocks: // - If signature is OUR cached signature, pass through unchanged // - Otherwise inject sentinel to bypass Antigravity validation // NOTE: We can't trust signatures just because they're >= 50 chars - Claude returns // its own signatures which are long but invalid for Antigravity. if (isLastAssistantMessage && (isThinking || hasSignature)) { // First check if it's our cached signature if (isOurCachedSignature(item, sessionId, getCachedSignatureFn)) { const sanitized = sanitizeThinkingPart(item); if (sanitized) filtered.push(sanitized); continue; } // Not our signature (or no signature) - inject sentinel const thinkingText = getThinkingText(item) || ""; const existingSignature = item.signature || item.thoughtSignature; const signatureInfo = existingSignature ? `foreign signature (${String(existingSignature).length} chars)` : "no signature"; log.debug(`Injecting sentinel for last-message thinking block with ${signatureInfo}`); const sentinelPart = { type: item.type || "thinking", thinking: thinkingText, signature: SKIP_THOUGHT_SIGNATURE, }; filtered.push(sentinelPart); continue; } if (isOurCachedSignature(item, sessionId, getCachedSignatureFn)) { const sanitized = sanitizeThinkingPart(item); if (sanitized) filtered.push(sanitized); continue; } if (sessionId && getCachedSignatureFn) { const text = getThinkingText(item); if (text) { const cachedSignature = getCachedSignatureFn(sessionId, text); if (cachedSignature && cachedSignature.length >= 50) { const restoredPart = { ...item }; if ((item as any).thought === true) { (restoredPart as any).thoughtSignature = cachedSignature; } else { (restoredPart as any).signature = cachedSignature; } const sanitized = sanitizeThinkingPart(restoredPart as Record); if (sanitized) filtered.push(sanitized); continue; } } } } return filtered; } /** * Filters thinking blocks from contents unless the signature matches our cache. * Attempts to restore signatures from cache for thinking blocks that lack signatures. * * @param contents - The contents array from the request * @param sessionId - Optional session ID for signature cache lookup * @param getCachedSignatureFn - Optional function to retrieve cached signatures */ export function filterUnsignedThinkingBlocks( contents: any[], sessionId?: string, getCachedSignatureFn?: (sessionId: string, text: string) => string | undefined, isClaudeModel?: boolean, ): any[] { const lastAssistantIdx = findLastAssistantIndex(contents, "model"); return contents.map((content: any, idx: number) => { if (!content || typeof content !== "object") { return content; } const isLastAssistant = idx === lastAssistantIdx; if (Array.isArray((content as any).parts)) { const filteredParts = filterContentArray( (content as any).parts, sessionId, getCachedSignatureFn, isClaudeModel, isLastAssistant, ); const trimmedParts = (content as any).role === "model" && !isClaudeModel ? removeTrailingThinkingBlocks(filteredParts, sessionId, getCachedSignatureFn) : filteredParts; return { ...content, parts: trimmedParts }; } if (Array.isArray((content as any).content)) { const isAssistantRole = (content as any).role === "assistant"; const isLastAssistantContent = idx === lastAssistantIdx || (isAssistantRole && idx === findLastAssistantIndex(contents, "assistant")); const filteredContent = filterContentArray( (content as any).content, sessionId, getCachedSignatureFn, isClaudeModel, isLastAssistantContent, ); const trimmedContent = isAssistantRole && !isClaudeModel ? removeTrailingThinkingBlocks(filteredContent, sessionId, getCachedSignatureFn) : filteredContent; return { ...content, content: trimmedContent }; } return content; }); } /** * Filters thinking blocks from Anthropic-style messages[] payloads using cached signatures. */ export function filterMessagesThinkingBlocks( messages: any[], sessionId?: string, getCachedSignatureFn?: (sessionId: string, text: string) => string | undefined, isClaudeModel?: boolean, ): any[] { const lastAssistantIdx = findLastAssistantIndex(messages, "assistant"); return messages.map((message: any, idx: number) => { if (!message || typeof message !== "object") { return message; } if (Array.isArray((message as any).content)) { const isAssistantRole = (message as any).role === "assistant"; const isLastAssistant = isAssistantRole && idx === lastAssistantIdx; const filteredContent = filterContentArray( (message as any).content, sessionId, getCachedSignatureFn, isClaudeModel, isLastAssistant, ); const trimmedContent = isAssistantRole && !isClaudeModel ? removeTrailingThinkingBlocks(filteredContent, sessionId, getCachedSignatureFn) : filteredContent; return { ...message, content: trimmedContent }; } return message; }); } export function deepFilterThinkingBlocks( payload: unknown, sessionId?: string, getCachedSignatureFn?: (sessionId: string, text: string) => string | undefined, isClaudeModel?: boolean, ): unknown { const visited = new WeakSet(); const walk = (value: unknown): void => { if (!value || typeof value !== "object") { return; } if (visited.has(value as object)) { return; } visited.add(value as object); if (Array.isArray(value)) { value.forEach((item) => walk(item)); return; } const obj = value as Record; if (Array.isArray(obj.contents)) { obj.contents = filterUnsignedThinkingBlocks( obj.contents as any[], sessionId, getCachedSignatureFn, isClaudeModel, ); } if (Array.isArray(obj.messages)) { obj.messages = filterMessagesThinkingBlocks( obj.messages as any[], sessionId, getCachedSignatureFn, isClaudeModel, ); } Object.keys(obj).forEach((key) => walk(obj[key])); }; walk(payload); return payload; } /** * Transforms Gemini-style thought parts (thought: true) and Anthropic-style * thinking parts (type: "thinking") to reasoning format. * Claude responses through Antigravity may use candidates structure with Anthropic-style parts. */ function transformGeminiCandidate(candidate: any): any { if (!candidate || typeof candidate !== "object") { return candidate; } const content = candidate.content; if (!content || typeof content !== "object" || !Array.isArray(content.parts)) { return candidate; } const thinkingTexts: string[] = []; const transformedParts = content.parts.map((part: any) => { if (!part || typeof part !== "object") { return part; } // Handle Gemini-style: thought: true if (part.thought === true) { const thinkingText = part.text || ""; thinkingTexts.push(thinkingText); const transformed: Record = { ...part, type: "reasoning" }; if (part.cache_control) transformed.cache_control = part.cache_control; // Convert signature to providerMetadata format for OpenCode const sig = part.signature || part.thoughtSignature; if (sig) { transformed.providerMetadata = { anthropic: { signature: sig } }; delete (transformed as any).signature; delete (transformed as any).thoughtSignature; } return transformed; } // Handle Anthropic-style in candidates: type: "thinking" if (part.type === "thinking") { const thinkingText = part.thinking || part.text || ""; thinkingTexts.push(thinkingText); const transformed: Record = { ...part, type: "reasoning", text: thinkingText, thought: true, }; if (part.cache_control) transformed.cache_control = part.cache_control; // Convert signature to providerMetadata format for OpenCode const sig = part.signature || part.thoughtSignature; if (sig) { transformed.providerMetadata = { anthropic: { signature: sig } }; delete (transformed as any).signature; delete (transformed as any).thoughtSignature; } return transformed; } // Handle functionCall: parse JSON strings in args and ensure args is always defined // (Ported from LLM-API-Key-Proxy's _extract_tool_call) // Fix: When Claude calls a tool with no parameters, args may be undefined. // opencode expects state.input to be a record, so we must ensure args: {} as fallback. if (part.functionCall) { const parsedArgs = part.functionCall.args ? recursivelyParseJsonStrings(part.functionCall.args) : {}; return { ...part, functionCall: { ...part.functionCall, args: parsedArgs, }, }; } // Handle image data (inlineData) - save to disk and return file path if (part.inlineData) { const result = processImageData({ mimeType: part.inlineData.mimeType, data: part.inlineData.data, }); if (result) { return { text: result }; } } return part; }); return { ...candidate, content: { ...content, parts: transformedParts }, ...(thinkingTexts.length > 0 ? { reasoning_content: thinkingTexts.join("\n\n") } : {}), }; } /** * Transforms thinking/reasoning content in response parts to OpenCode's expected format. * Handles both Gemini-style (thought: true) and Anthropic-style (type: "thinking") formats. * Also extracts reasoning_content for Anthropic-style responses. */ export function transformThinkingParts(response: unknown): unknown { if (!response || typeof response !== "object") { return response; } const resp = response as Record; const result: Record = { ...resp }; const reasoningTexts: string[] = []; // Handle Anthropic-style content array (type: "thinking") if (Array.isArray(resp.content)) { const transformedContent: any[] = []; for (const block of resp.content) { if (block && typeof block === "object" && (block as any).type === "thinking") { const thinkingText = (block as any).thinking || (block as any).text || ""; reasoningTexts.push(thinkingText); const transformed: Record = { ...block, type: "reasoning", text: thinkingText, thought: true, }; // Convert signature to providerMetadata format for OpenCode const sig = (block as any).signature || (block as any).thoughtSignature; if (sig) { transformed.providerMetadata = { anthropic: { signature: sig } }; delete (transformed as any).signature; delete (transformed as any).thoughtSignature; } transformedContent.push(transformed); } else { transformedContent.push(block); } } result.content = transformedContent; } // Handle Gemini-style candidates array if (Array.isArray(resp.candidates)) { result.candidates = resp.candidates.map(transformGeminiCandidate); } // Add reasoning_content if we found any thinking blocks (for Anthropic-style) if (reasoningTexts.length > 0 && !result.reasoning_content) { result.reasoning_content = reasoningTexts.join("\n\n"); } return result; } /** * Ensures thinkingConfig is valid: includeThoughts only allowed when budget > 0. */ export function normalizeThinkingConfig(config: unknown): ThinkingConfig | undefined { if (!config || typeof config !== "object") { return undefined; } const record = config as Record; const budgetRaw = record.thinkingBudget ?? record.thinking_budget; const includeRaw = record.includeThoughts ?? record.include_thoughts; const thinkingBudget = typeof budgetRaw === "number" && Number.isFinite(budgetRaw) ? budgetRaw : undefined; const includeThoughts = typeof includeRaw === "boolean" ? includeRaw : undefined; const enableThinking = thinkingBudget !== undefined && thinkingBudget > 0; const finalInclude = enableThinking ? includeThoughts ?? false : false; if (!enableThinking && finalInclude === false && thinkingBudget === undefined && includeThoughts === undefined) { return undefined; } const normalized: ThinkingConfig = {}; if (thinkingBudget !== undefined) { normalized.thinkingBudget = thinkingBudget; } if (finalInclude !== undefined) { normalized.includeThoughts = finalInclude; } return normalized; } /** * Parses an Antigravity API body; handles array-wrapped responses the API sometimes returns. */ export function parseAntigravityApiBody(rawText: string): AntigravityApiBody | null { try { const parsed = JSON.parse(rawText); if (Array.isArray(parsed)) { const firstObject = parsed.find((item: unknown) => typeof item === "object" && item !== null); if (firstObject && typeof firstObject === "object") { return firstObject as AntigravityApiBody; } return null; } if (parsed && typeof parsed === "object") { return parsed as AntigravityApiBody; } return null; } catch { return null; } } /** * Extracts usageMetadata from a response object, guarding types. */ export function extractUsageMetadata(body: AntigravityApiBody): AntigravityUsageMetadata | null { const usage = (body.response && typeof body.response === "object" ? (body.response as { usageMetadata?: unknown }).usageMetadata : undefined) as AntigravityUsageMetadata | undefined; if (!usage || typeof usage !== "object") { return null; } const asRecord = usage as Record; const toNumber = (value: unknown): number | undefined => typeof value === "number" && Number.isFinite(value) ? value : undefined; return { totalTokenCount: toNumber(asRecord.totalTokenCount), promptTokenCount: toNumber(asRecord.promptTokenCount), candidatesTokenCount: toNumber(asRecord.candidatesTokenCount), cachedContentTokenCount: toNumber(asRecord.cachedContentTokenCount), thoughtsTokenCount: toNumber(asRecord.thoughtsTokenCount), }; } /** * Walks SSE lines to find a usage-bearing response chunk. */ export function extractUsageFromSsePayload(payload: string): AntigravityUsageMetadata | null { const lines = payload.split("\n"); for (const line of lines) { if (!line.startsWith("data:")) { continue; } const jsonText = line.slice(5).trim(); if (!jsonText) { continue; } try { const parsed = JSON.parse(jsonText); if (parsed && typeof parsed === "object") { const usage = extractUsageMetadata({ response: (parsed as Record).response }); if (usage) { return usage; } } } catch { continue; } } return null; } /** * Enhances 404 errors for Antigravity models with a direct preview-access message. */ export function rewriteAntigravityPreviewAccessError( body: AntigravityApiBody, status: number, requestedModel?: string, ): AntigravityApiBody | null { if (!needsPreviewAccessOverride(status, body, requestedModel)) { return null; } const error: AntigravityApiError = body.error ?? {}; const trimmedMessage = typeof error.message === "string" ? error.message.trim() : ""; const messagePrefix = trimmedMessage.length > 0 ? trimmedMessage : "Antigravity preview features are not enabled for this account."; const enhancedMessage = `${messagePrefix} Request preview access at ${ANTIGRAVITY_PREVIEW_LINK} before using this model.`; return { ...body, error: { ...error, message: enhancedMessage, }, }; } function needsPreviewAccessOverride( status: number, body: AntigravityApiBody, requestedModel?: string, ): boolean { if (status !== 404) { return false; } if (isAntigravityModel(requestedModel)) { return true; } const errorMessage = typeof body.error?.message === "string" ? body.error.message : ""; return isAntigravityModel(errorMessage); } function isAntigravityModel(target?: string): boolean { if (!target) { return false; } // Check for Antigravity models instead of Gemini 3 return /antigravity/i.test(target) || /opus/i.test(target) || /claude/i.test(target); } // ============================================================================ // EMPTY RESPONSE DETECTION (Ported from LLM-API-Key-Proxy) // ============================================================================ /** * Checks if a JSON response body represents an empty response. * * Empty responses occur when: * - No candidates in Gemini format * - No choices in OpenAI format * - Candidates/choices exist but have no content * * @param text - The response body text (should be valid JSON) * @returns true if the response is empty */ export function isEmptyResponseBody(text: string): boolean { if (!text || !text.trim()) { return true; } try { const parsed = JSON.parse(text); // Check for empty candidates (Gemini/Antigravity format) if (parsed.candidates !== undefined) { if (!Array.isArray(parsed.candidates) || parsed.candidates.length === 0) { return true; } // Check if first candidate has empty content const firstCandidate = parsed.candidates[0]; if (!firstCandidate) { return true; } // Check for empty parts in content const content = firstCandidate.content; if (!content || typeof content !== "object") { return true; } const parts = content.parts; if (!Array.isArray(parts) || parts.length === 0) { return true; } // Check if all parts are empty (no text, no functionCall) const hasContent = parts.some((part: any) => { if (!part || typeof part !== "object") return false; if (typeof part.text === "string" && part.text.length > 0) return true; if (part.functionCall) return true; if (part.thought === true && typeof part.text === "string") return true; return false; }); if (!hasContent) { return true; } } // Check for empty choices (OpenAI format - shouldn't occur but handle it) if (parsed.choices !== undefined) { if (!Array.isArray(parsed.choices) || parsed.choices.length === 0) { return true; } const firstChoice = parsed.choices[0]; if (!firstChoice) { return true; } // Check for empty message/delta const message = firstChoice.message || firstChoice.delta; if (!message) { return true; } // Check if message has content or tool_calls if (!message.content && !message.tool_calls && !message.reasoning_content) { return true; } } // Check response wrapper (Antigravity envelope) if (parsed.response !== undefined) { const response = parsed.response; if (!response || typeof response !== "object") { return true; } return isEmptyResponseBody(JSON.stringify(response)); } return false; } catch { // JSON parse error - treat as empty return true; } } /** * Checks if a streaming SSE response yielded zero meaningful chunks. * * This is used after consuming a streaming response to determine if retry is needed. */ export interface StreamingChunkCounter { increment: () => void; getCount: () => number; hasContent: () => boolean; } export function createStreamingChunkCounter(): StreamingChunkCounter { let count = 0; let hasRealContent = false; return { increment: () => { count++; }, getCount: () => count, hasContent: () => hasRealContent || count > 0, }; } /** * Checks if an SSE line contains meaningful content. * * @param line - A single SSE line (e.g., "data: {...}") * @returns true if the line contains content worth counting */ export function isMeaningfulSseLine(line: string): boolean { if (!line.startsWith("data: ")) { return false; } const data = line.slice(6).trim(); if (data === "[DONE]") { return false; } if (!data) { return false; } try { const parsed = JSON.parse(data); // Check for candidates with content if (parsed.candidates && Array.isArray(parsed.candidates)) { for (const candidate of parsed.candidates) { const parts = candidate?.content?.parts; if (Array.isArray(parts) && parts.length > 0) { for (const part of parts) { if (typeof part?.text === "string" && part.text.length > 0) return true; if (part?.functionCall) return true; } } } } // Check response wrapper if (parsed.response?.candidates) { return isMeaningfulSseLine(`data: ${JSON.stringify(parsed.response)}`); } return false; } catch { return false; } } // ============================================================================ // RECURSIVE JSON STRING AUTO-PARSING (Ported from LLM-API-Key-Proxy) // ============================================================================ /** * Recursively parses JSON strings in nested data structures. * * This is a port of LLM-API-Key-Proxy's _recursively_parse_json_strings() function. * * Handles: * - JSON-stringified values: {"files": "[{...}]"} → {"files": [{...}]} * - Malformed double-encoded JSON (extra trailing chars) * - Escaped control characters (\\n → \n, \\t → \t) * * This is useful because Antigravity sometimes returns JSON-stringified values * in tool arguments, which can cause downstream parsing issues. * * @param obj - The object to recursively parse * @param skipParseKeys - Set of keys whose values should NOT be parsed as JSON (preserved as strings) * @param currentKey - The current key being processed (internal use) * @returns The parsed object with JSON strings expanded */ // Keys whose string values should NOT be parsed as JSON - they contain literal text content const SKIP_PARSE_KEYS = new Set([ "oldString", "newString", "content", "filePath", "path", "text", "code", "source", "data", "body", "message", "prompt", "input", "output", "result", "value", "query", "pattern", "replacement", "template", "script", "command", "snippet", ]); export function recursivelyParseJsonStrings( obj: unknown, skipParseKeys: Set = SKIP_PARSE_KEYS, currentKey?: string, ): unknown { if (obj === null || obj === undefined) { return obj; } if (Array.isArray(obj)) { return obj.map((item) => recursivelyParseJsonStrings(item, skipParseKeys)); } if (typeof obj === "object") { const result: Record = {}; for (const [key, value] of Object.entries(obj)) { result[key] = recursivelyParseJsonStrings(value, skipParseKeys, key); } return result; } if (typeof obj !== "string") { return obj; } if (currentKey && skipParseKeys.has(currentKey)) { return obj; } const stripped = obj.trim(); // Check if string contains control character escape sequences // that need unescaping (\\n, \\t but NOT \\" or \\\\) const hasControlCharEscapes = obj.includes("\\n") || obj.includes("\\t"); const hasIntentionalEscapes = obj.includes('\\"') || obj.includes("\\\\"); if (hasControlCharEscapes && !hasIntentionalEscapes) { try { // Use JSON.parse with quotes to unescape the string return JSON.parse(`"${obj}"`); } catch { // Continue with original processing } } // Check if it looks like JSON (starts with { or [) if (stripped && (stripped[0] === "{" || stripped[0] === "[")) { // Try standard parsing first if ( (stripped.startsWith("{") && stripped.endsWith("}")) || (stripped.startsWith("[") && stripped.endsWith("]")) ) { try { const parsed = JSON.parse(obj); return recursivelyParseJsonStrings(parsed); } catch { // Continue } } // Handle malformed JSON: array that doesn't end with ] if (stripped.startsWith("[") && !stripped.endsWith("]")) { try { const lastBracket = stripped.lastIndexOf("]"); if (lastBracket > 0) { const cleaned = stripped.slice(0, lastBracket + 1); const parsed = JSON.parse(cleaned); log.debug("Auto-corrected malformed JSON array", { truncatedChars: stripped.length - cleaned.length, }); return recursivelyParseJsonStrings(parsed); } } catch { // Continue } } // Handle malformed JSON: object that doesn't end with } if (stripped.startsWith("{") && !stripped.endsWith("}")) { try { const lastBrace = stripped.lastIndexOf("}"); if (lastBrace > 0) { const cleaned = stripped.slice(0, lastBrace + 1); const parsed = JSON.parse(cleaned); log.debug("Auto-corrected malformed JSON object", { truncatedChars: stripped.length - cleaned.length, }); return recursivelyParseJsonStrings(parsed); } } catch { // Continue } } } return obj; } // ============================================================================ // TOOL ID ORPHAN RECOVERY (Ported from LLM-API-Key-Proxy) // ============================================================================ /** * Groups function calls with their responses, handling ID mismatches. * * This is a port of LLM-API-Key-Proxy's _fix_tool_response_grouping() function. * * When context compaction or other processes strip tool responses, the tool call * IDs become orphaned. This function attempts to recover by: * * 1. Pass 1: Match by exact ID (normal case) * 2. Pass 2: Match by function name (for ID mismatches) * 3. Pass 3: Match "unknown_function" orphans or take first available * 4. Fallback: Create placeholder responses for missing tool results * * @param contents - Array of Gemini-style content messages * @returns Fixed contents array with matched tool responses */ export function fixToolResponseGrouping(contents: any[]): any[] { if (!Array.isArray(contents) || contents.length === 0) { return contents; } const newContents: any[] = []; // Track pending tool call groups that need responses const pendingGroups: Array<{ ids: string[]; funcNames: string[]; insertAfterIdx: number; }> = []; // Collected orphan responses (by ID) const collectedResponses = new Map(); for (const content of contents) { const role = content.role; const parts = content.parts || []; // Check if this is a tool response message const responseParts = parts.filter((p: any) => p?.functionResponse); if (responseParts.length > 0) { // Collect responses by ID (skip duplicates) for (const resp of responseParts) { const respId = resp.functionResponse?.id || ""; if (respId && !collectedResponses.has(respId)) { collectedResponses.set(respId, resp); } } // Try to satisfy the most recent pending group for (let i = pendingGroups.length - 1; i >= 0; i--) { const group = pendingGroups[i]!; if (group.ids.every(id => collectedResponses.has(id))) { // All IDs found - build the response group const groupResponses = group.ids.map(id => { const resp = collectedResponses.get(id); collectedResponses.delete(id); return resp; }); newContents.push({ parts: groupResponses, role: "user" }); pendingGroups.splice(i, 1); break; // Only satisfy one group at a time } } continue; // Don't add the original response message } if (role === "model") { // Check for function calls in this model message const funcCalls = parts.filter((p: any) => p?.functionCall); newContents.push(content); if (funcCalls.length > 0) { const callIds = funcCalls .map((fc: any) => fc.functionCall?.id || "") .filter(Boolean); const funcNames = funcCalls .map((fc: any) => fc.functionCall?.name || ""); if (callIds.length > 0) { pendingGroups.push({ ids: callIds, funcNames, insertAfterIdx: newContents.length - 1, }); } } } else { newContents.push(content); } } // Handle remaining pending groups with orphan recovery // Process in reverse order so insertions don't shift indices pendingGroups.sort((a, b) => b.insertAfterIdx - a.insertAfterIdx); for (const group of pendingGroups) { const groupResponses: any[] = []; for (let i = 0; i < group.ids.length; i++) { const expectedId = group.ids[i]!; const expectedName = group.funcNames[i] || ""; if (collectedResponses.has(expectedId)) { // Direct ID match - ideal case groupResponses.push(collectedResponses.get(expectedId)); collectedResponses.delete(expectedId); } else if (collectedResponses.size > 0) { // Need to find an orphan response let matchedId: string | null = null; // Pass 1: Match by function name for (const [orphanId, orphanResp] of collectedResponses) { const orphanName = orphanResp.functionResponse?.name || ""; if (orphanName === expectedName) { matchedId = orphanId; break; } } // Pass 2: Match "unknown_function" orphans if (!matchedId) { for (const [orphanId, orphanResp] of collectedResponses) { if (orphanResp.functionResponse?.name === "unknown_function") { matchedId = orphanId; break; } } } // Pass 3: Take first available if (!matchedId) { matchedId = collectedResponses.keys().next().value ?? null; } if (matchedId) { const orphanResp = collectedResponses.get(matchedId)!; collectedResponses.delete(matchedId); // Fix the ID and name to match expected orphanResp.functionResponse.id = expectedId; if (orphanResp.functionResponse.name === "unknown_function" && expectedName) { orphanResp.functionResponse.name = expectedName; } log.debug("Auto-repaired tool ID mismatch", { mappedFrom: matchedId, mappedTo: expectedId, functionName: expectedName, }); groupResponses.push(orphanResp); } } else { // No responses available - create placeholder const placeholder = { functionResponse: { name: expectedName || "unknown_function", response: { result: { error: "Tool response was lost during context processing. " + "This is a recovered placeholder.", recovered: true, }, }, id: expectedId, }, }; log.debug("Created placeholder response for missing tool", { id: expectedId, name: expectedName, }); groupResponses.push(placeholder); } } if (groupResponses.length > 0) { // Insert at correct position (after the model message that made the calls) newContents.splice(group.insertAfterIdx + 1, 0, { parts: groupResponses, role: "user", }); } } return newContents; } /** * Checks if contents have any tool call/response ID mismatches. * * @param contents - Array of Gemini-style content messages * @returns Object with mismatch details */ export function detectToolIdMismatches(contents: any[]): { hasMismatches: boolean; expectedIds: string[]; foundIds: string[]; missingIds: string[]; orphanIds: string[]; } { const expectedIds: string[] = []; const foundIds: string[] = []; for (const content of contents) { const parts = content.parts || []; for (const part of parts) { if (part?.functionCall?.id) { expectedIds.push(part.functionCall.id); } if (part?.functionResponse?.id) { foundIds.push(part.functionResponse.id); } } } const expectedSet = new Set(expectedIds); const foundSet = new Set(foundIds); const missingIds = expectedIds.filter(id => !foundSet.has(id)); const orphanIds = foundIds.filter(id => !expectedSet.has(id)); return { hasMismatches: missingIds.length > 0 || orphanIds.length > 0, expectedIds, foundIds, missingIds, orphanIds, }; } // ============================================================================ // CLAUDE FORMAT TOOL PAIRING (Defense in Depth) // ============================================================================ /** * Find orphaned tool_use IDs (tool_use without matching tool_result). * Works on Claude format messages. */ export function findOrphanedToolUseIds(messages: any[]): Set { const toolUseIds = new Set(); const toolResultIds = new Set(); for (const msg of messages) { if (Array.isArray(msg.content)) { for (const block of msg.content) { if (block.type === "tool_use" && block.id) { toolUseIds.add(block.id); } if (block.type === "tool_result" && block.tool_use_id) { toolResultIds.add(block.tool_use_id); } } } } return new Set([...toolUseIds].filter((id) => !toolResultIds.has(id))); } /** * Fix orphaned tool_use blocks in Claude format messages. * Mirrors fixToolResponseGrouping() but for Claude's messages[] format. * * Claude format: * - assistant message with content[]: { type: 'tool_use', id, name, input } * - user message with content[]: { type: 'tool_result', tool_use_id, content } * * @param messages - Claude format messages array * @returns Fixed messages with placeholder tool_results for orphans */ export function fixClaudeToolPairing(messages: any[]): any[] { if (!Array.isArray(messages) || messages.length === 0) { return messages; } // 1. Collect all tool_use IDs from assistant messages const toolUseMap = new Map(); for (let i = 0; i < messages.length; i++) { const msg = messages[i]; if (msg.role === "assistant" && Array.isArray(msg.content)) { for (const block of msg.content) { if (block.type === "tool_use" && block.id) { toolUseMap.set(block.id, { name: block.name || `tool-${toolUseMap.size}`, msgIndex: i }); } } } } // 2. Collect all tool_result IDs from user messages const toolResultIds = new Set(); for (const msg of messages) { if (msg.role === "user" && Array.isArray(msg.content)) { for (const block of msg.content) { if (block.type === "tool_result" && block.tool_use_id) { toolResultIds.add(block.tool_use_id); } } } } // 3. Find orphaned tool_use (no matching tool_result) const orphans: Array<{ id: string; name: string; msgIndex: number }> = []; for (const [id, info] of toolUseMap) { if (!toolResultIds.has(id)) { orphans.push({ id, ...info }); } } if (orphans.length === 0) { return messages; } // 4. Group orphans by message index (insert after each assistant message) const orphansByMsgIndex = new Map(); for (const orphan of orphans) { const existing = orphansByMsgIndex.get(orphan.msgIndex) || []; existing.push(orphan); orphansByMsgIndex.set(orphan.msgIndex, existing); } // 5. Build new messages array with injected tool_results const result: any[] = []; for (let i = 0; i < messages.length; i++) { result.push(messages[i]); const orphansForMsg = orphansByMsgIndex.get(i); if (orphansForMsg && orphansForMsg.length > 0) { // Check if next message is user with tool_result - if so, merge into it const nextMsg = messages[i + 1]; if (nextMsg?.role === "user" && Array.isArray(nextMsg.content)) { // Will be handled when we push nextMsg - add to its content const placeholders = orphansForMsg.map((o) => ({ type: "tool_result", tool_use_id: o.id, content: `[Tool "${o.name}" execution was cancelled or failed]`, is_error: true, })); // Prepend placeholders to next message's content nextMsg.content = [...placeholders, ...nextMsg.content]; } else { // Inject new user message with placeholder tool_results result.push({ role: "user", content: orphansForMsg.map((o) => ({ type: "tool_result", tool_use_id: o.id, content: `[Tool "${o.name}" execution was cancelled or failed]`, is_error: true, })), }); } } } return result; } /** * Nuclear option: Remove orphaned tool_use blocks entirely. * Called when fixClaudeToolPairing() fails to pair all tools. */ function removeOrphanedToolUse(messages: any[], orphanIds: Set): any[] { return messages .map((msg) => { if (msg.role === "assistant" && Array.isArray(msg.content)) { return { ...msg, content: msg.content.filter( (block: any) => block.type !== "tool_use" || !orphanIds.has(block.id) ), }; } return msg; }) .filter( (msg) => // Remove empty assistant messages !(msg.role === "assistant" && Array.isArray(msg.content) && msg.content.length === 0) ); } /** * Validate and fix tool pairing with fallback nuclear option. * Defense in depth: tries gentle fix first, then nuclear removal. */ export function validateAndFixClaudeToolPairing(messages: any[]): any[] { if (!Array.isArray(messages) || messages.length === 0) { return messages; } // First: Try gentle fix (inject placeholder tool_results) let fixed = fixClaudeToolPairing(messages); // Second: Validate - find any remaining orphans const orphanIds = findOrphanedToolUseIds(fixed); if (orphanIds.size === 0) { return fixed; } // Third: Nuclear option - remove orphaned tool_use entirely // This should rarely happen, but provides defense in depth console.warn("[antigravity] fixClaudeToolPairing left orphans, applying nuclear option", { orphanIds: [...orphanIds], }); return removeOrphanedToolUse(fixed, orphanIds); } // ============================================================================ // TOOL HALLUCINATION PREVENTION (Ported from LLM-API-Key-Proxy) // ============================================================================ /** * Formats a type hint for a property schema. * Port of LLM-API-Key-Proxy's _format_type_hint() */ function formatTypeHint(propData: Record, depth = 0): string { const type = propData.type as string ?? "unknown"; // Handle enum values if (propData.enum && Array.isArray(propData.enum)) { const enumVals = propData.enum as unknown[]; if (enumVals.length <= 5) { return `string ENUM[${enumVals.map(v => JSON.stringify(v)).join(", ")}]`; } return `string ENUM[${enumVals.length} options]`; } // Handle const values if (propData.const !== undefined) { return `string CONST=${JSON.stringify(propData.const)}`; } if (type === "array") { const items = propData.items as Record | undefined; if (items && typeof items === "object") { const itemType = items.type as string ?? "unknown"; if (itemType === "object") { const nestedProps = items.properties as Record | undefined; const nestedReq = items.required as string[] | undefined ?? []; if (nestedProps && depth < 1) { const nestedList = Object.entries(nestedProps).map(([n, d]) => { const t = (d as Record).type as string ?? "unknown"; const req = nestedReq.includes(n) ? " REQUIRED" : ""; return `${n}: ${t}${req}`; }); return `ARRAY_OF_OBJECTS[${nestedList.join(", ")}]`; } return "ARRAY_OF_OBJECTS"; } return `ARRAY_OF_${itemType.toUpperCase()}`; } return "ARRAY"; } if (type === "object") { const nestedProps = propData.properties as Record | undefined; const nestedReq = propData.required as string[] | undefined ?? []; if (nestedProps && depth < 1) { const nestedList = Object.entries(nestedProps).map(([n, d]) => { const t = (d as Record).type as string ?? "unknown"; const req = nestedReq.includes(n) ? " REQUIRED" : ""; return `${n}: ${t}${req}`; }); return `object{${nestedList.join(", ")}}`; } } return type; } /** * Injects parameter signatures into tool descriptions. * Port of LLM-API-Key-Proxy's _inject_signature_into_descriptions() * * This helps prevent tool hallucination by explicitly listing parameters * in the description, making it harder for the model to hallucinate * parameters from its training data. * * @param tools - Array of tool definitions (Gemini format) * @param promptTemplate - Template for the signature (default: "\\n\\nSTRICT PARAMETERS: {params}.") * @returns Modified tools array with signatures injected */ export function injectParameterSignatures( tools: any[], promptTemplate = "\n\n⚠️ STRICT PARAMETERS: {params}.", ): any[] { if (!tools || !Array.isArray(tools)) return tools; return tools.map((tool) => { const declarations = tool.functionDeclarations; if (!Array.isArray(declarations)) return tool; const newDeclarations = declarations.map((decl: any) => { // Skip if signature already injected (avoids duplicate injection) if (decl.description?.includes("STRICT PARAMETERS:")) { return decl; } const schema = decl.parameters || decl.parametersJsonSchema; if (!schema) return decl; const required = schema.required as string[] ?? []; const properties = schema.properties as Record ?? {}; if (Object.keys(properties).length === 0) return decl; const paramList = Object.entries(properties).map(([propName, propData]) => { const typeHint = formatTypeHint(propData as Record); const isRequired = required.includes(propName); return `${propName} (${typeHint}${isRequired ? ", REQUIRED" : ""})`; }); const sigStr = promptTemplate.replace("{params}", paramList.join(", ")); return { ...decl, description: (decl.description || "") + sigStr, }; }); return { ...tool, functionDeclarations: newDeclarations }; }); } /** * Injects a tool hardening system instruction into the request payload. * Port of LLM-API-Key-Proxy's _inject_tool_hardening_instruction() * * @param payload - The Gemini request payload * @param instructionText - The instruction text to inject */ export function injectToolHardeningInstruction( payload: Record, instructionText: string, ): void { if (!instructionText) return; // Skip if instruction already present (avoids duplicate injection) const existing = payload.systemInstruction as Record | undefined; if (existing && typeof existing === "object" && "parts" in existing) { const parts = existing.parts as Array<{ text?: string }>; if (Array.isArray(parts) && parts.some(p => p.text?.includes("CRITICAL TOOL USAGE INSTRUCTIONS"))) { return; } } const instructionPart = { text: instructionText }; if (payload.systemInstruction) { if (existing && typeof existing === "object" && "parts" in existing) { const parts = existing.parts as unknown[]; if (Array.isArray(parts)) { parts.unshift(instructionPart); } } else if (typeof existing === "string") { payload.systemInstruction = { role: "user", parts: [instructionPart, { text: existing }], }; } else { payload.systemInstruction = { role: "user", parts: [instructionPart], }; } } else { payload.systemInstruction = { role: "user", parts: [instructionPart], }; } } // ============================================================================ // TOOL PROCESSING FOR WRAPPED REQUESTS // Shared logic for assigning tool IDs and fixing tool pairing // ============================================================================ /** * Assigns IDs to functionCall parts and returns the pending call IDs by name. * This is the first pass of tool ID assignment. * * @param contents - Gemini-style contents array * @returns Object with modified contents and pending call IDs map */ export function assignToolIdsToContents( contents: any[] ): { contents: any[]; pendingCallIdsByName: Map; toolCallCounter: number } { if (!Array.isArray(contents)) { return { contents, pendingCallIdsByName: new Map(), toolCallCounter: 0 }; } let toolCallCounter = 0; const pendingCallIdsByName = new Map(); const newContents = contents.map((content: any) => { if (!content || !Array.isArray(content.parts)) { return content; } const newParts = content.parts.map((part: any) => { if (part && typeof part === "object" && part.functionCall) { const call = { ...part.functionCall }; if (!call.id) { call.id = `tool-call-${++toolCallCounter}`; } const nameKey = typeof call.name === "string" ? call.name : `tool-${toolCallCounter}`; const queue = pendingCallIdsByName.get(nameKey) || []; queue.push(call.id); pendingCallIdsByName.set(nameKey, queue); return { ...part, functionCall: call }; } return part; }); return { ...content, parts: newParts }; }); return { contents: newContents, pendingCallIdsByName, toolCallCounter }; } /** * Matches functionResponse IDs to their corresponding functionCall IDs. * This is the second pass of tool ID assignment. * * @param contents - Gemini-style contents array * @param pendingCallIdsByName - Map of function names to pending call IDs * @returns Modified contents with matched response IDs */ export function matchResponseIdsToContents( contents: any[], pendingCallIdsByName: Map ): any[] { if (!Array.isArray(contents)) { return contents; } return contents.map((content: any) => { if (!content || !Array.isArray(content.parts)) { return content; } const newParts = content.parts.map((part: any) => { if (part && typeof part === "object" && part.functionResponse) { const resp = { ...part.functionResponse }; if (!resp.id && typeof resp.name === "string") { const queue = pendingCallIdsByName.get(resp.name); if (queue && queue.length > 0) { resp.id = queue.shift(); pendingCallIdsByName.set(resp.name, queue); } } return { ...part, functionResponse: resp }; } return part; }); return { ...content, parts: newParts }; }); } /** * Applies all tool fixes to a request payload for Claude models. * This includes: * 1. Tool ID assignment for functionCalls * 2. Response ID matching for functionResponses * 3. Orphan recovery via fixToolResponseGrouping * 4. Claude format pairing fix via validateAndFixClaudeToolPairing * * @param payload - Request payload object * @param isClaude - Whether this is a Claude model request * @returns Object with fix applied status */ export function applyToolPairingFixes( payload: Record, isClaude: boolean ): { contentsFixed: boolean; messagesFixed: boolean } { let contentsFixed = false; let messagesFixed = false; if (!isClaude) { return { contentsFixed, messagesFixed }; } // Fix Gemini format (contents[]) if (Array.isArray(payload.contents)) { // First pass: assign IDs to functionCalls const { contents: contentsWithIds, pendingCallIdsByName } = assignToolIdsToContents( payload.contents as any[] ); // Second pass: match functionResponse IDs const contentsWithMatchedIds = matchResponseIdsToContents(contentsWithIds, pendingCallIdsByName); // Third pass: fix orphan recovery payload.contents = fixToolResponseGrouping(contentsWithMatchedIds); contentsFixed = true; log.debug("Applied tool pairing fixes to contents[]", { originalLength: (payload.contents as any[]).length, }); } // Fix Claude format (messages[]) if (Array.isArray(payload.messages)) { payload.messages = validateAndFixClaudeToolPairing(payload.messages as any[]); messagesFixed = true; log.debug("Applied tool pairing fixes to messages[]", { originalLength: (payload.messages as any[]).length, }); } return { contentsFixed, messagesFixed }; } // ============================================================================ // SYNTHETIC CLAUDE SSE RESPONSE // Used to return error messages as "successful" responses to avoid locking // the OpenCode session when unrecoverable errors (like 400 Prompt Too Long) occur. // ============================================================================ /** * Creates a synthetic Claude SSE streaming response with error content. * * When returning HTTP 400/500 errors to OpenCode, the session becomes locked * and the user cannot use /compact or other commands. This function creates * a fake "successful" SSE response (200 OK) with the error message as text content, * allowing the user to continue using the session. * * @param errorMessage - The error message to include in the response * @param requestedModel - The model that was requested * @returns A Response object with synthetic SSE stream */ export function createSyntheticErrorResponse( errorMessage: string, requestedModel: string = "unknown", ): Response { // Generate a unique message ID const messageId = `msg_synthetic_${Date.now()}`; // Build Claude SSE events that represent a complete message with error text const events: string[] = []; // 1. message_start event events.push(`event: message_start data: ${JSON.stringify({ type: "message_start", message: { id: messageId, type: "message", role: "assistant", content: [], model: requestedModel, stop_reason: null, stop_sequence: null, usage: { input_tokens: 0, output_tokens: 0 }, }, })} `); // 2. content_block_start event events.push(`event: content_block_start data: ${JSON.stringify({ type: "content_block_start", index: 0, content_block: { type: "text", text: "" }, })} `); // 3. content_block_delta event with the error message events.push(`event: content_block_delta data: ${JSON.stringify({ type: "content_block_delta", index: 0, delta: { type: "text_delta", text: errorMessage }, })} `); // 4. content_block_stop event events.push(`event: content_block_stop data: ${JSON.stringify({ type: "content_block_stop", index: 0, })} `); // 5. message_delta event (end_turn) events.push(`event: message_delta data: ${JSON.stringify({ type: "message_delta", delta: { stop_reason: "end_turn", stop_sequence: null }, usage: { output_tokens: Math.ceil(errorMessage.length / 4) }, })} `); // 6. message_stop event events.push(`event: message_stop data: ${JSON.stringify({ type: "message_stop" })} `); const body = events.join(""); return new Response(body, { status: 200, headers: { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", "Connection": "keep-alive", "X-Antigravity-Synthetic": "true", "X-Antigravity-Error-Type": "prompt_too_long", }, }); } ================================================ FILE: src/plugin/request.test.ts ================================================ import { describe, it, expect, vi } from "vitest"; import { prepareAntigravityRequest, transformAntigravityResponse, getPluginSessionId, isGenerativeLanguageRequest, __testExports, } from "./request"; import { DEFAULT_CONFIG } from "./config"; import { initializeDebug } from "./debug"; import { SKIP_THOUGHT_SIGNATURE } from "../constants"; import * as config from "./config"; import type { SignatureStore, ThoughtBuffer, StreamingCallbacks, StreamingOptions } from "./core/streaming/types"; const { buildSignatureSessionKey, hashConversationSeed, extractTextFromContent, extractConversationSeedFromMessages, extractConversationSeedFromContents, resolveProjectKey, isGeminiToolUsePart, isGeminiThinkingPart, ensureThoughtSignature, hasSignedThinkingPart, hasToolUseInContents, hasSignedThinkingInContents, hasToolUseInMessages, hasSignedThinkingInMessages, generateSyntheticProjectId, MIN_SIGNATURE_LENGTH, transformStreamingPayload, createStreamingTransformer, transformSseLine, } = __testExports; function createMockSignatureStore(): SignatureStore { const store = new Map(); return { get: (key: string) => store.get(key), set: (key: string, value: { text: string; signature: string }) => store.set(key, value), has: (key: string) => store.has(key), delete: (key: string) => store.delete(key), }; } function createMockThoughtBuffer(): ThoughtBuffer { const buffer = new Map(); return { get: (idx: number) => buffer.get(idx), set: (idx: number, text: string) => buffer.set(idx, text), clear: () => buffer.clear(), }; } const defaultCallbacks: StreamingCallbacks = {}; const defaultOptions: StreamingOptions = {}; const defaultDebugState = { injected: false }; function withKeepThinking(enabled: boolean, fn: () => T): T { const keepThinkingSpy = vi.spyOn(config, "getKeepThinking").mockReturnValue(enabled); try { return fn(); } finally { keepThinkingSpy.mockRestore(); } } describe("request.ts", () => { describe("getPluginSessionId", () => { it("returns consistent session ID across calls", () => { const id1 = getPluginSessionId(); const id2 = getPluginSessionId(); expect(id1).toBe(id2); expect(id1).toBeTruthy(); }); }); describe("isGenerativeLanguageRequest", () => { it("returns true for generativelanguage.googleapis.com URLs", () => { expect(isGenerativeLanguageRequest("https://generativelanguage.googleapis.com/v1/models")).toBe(true); }); it("returns false for other URLs", () => { expect(isGenerativeLanguageRequest("https://api.anthropic.com/v1/messages")).toBe(false); }); it("returns false for non-string inputs", () => { expect(isGenerativeLanguageRequest({} as any)).toBe(false); expect(isGenerativeLanguageRequest(new Request("https://example.com"))).toBe(false); }); }); describe("buildSignatureSessionKey", () => { it("builds key from sessionId, model, project, and conversation", () => { const key = buildSignatureSessionKey("session-1", "claude-3", "conv-456", "proj-123"); expect(key).toBe("session-1:claude-3:proj-123:conv-456"); }); it("uses defaults for missing optional params", () => { expect(buildSignatureSessionKey("s1", undefined, undefined, undefined)).toBe("s1:unknown:default:default"); expect(buildSignatureSessionKey("s1", "model", undefined, undefined)).toBe("s1:model:default:default"); }); it("handles empty strings as defaults", () => { expect(buildSignatureSessionKey("s1", "", "", "")).toBe("s1:unknown:default:default"); }); }); describe("hashConversationSeed", () => { it("returns consistent hash for same input", () => { const hash1 = hashConversationSeed("test-seed"); const hash2 = hashConversationSeed("test-seed"); expect(hash1).toBe(hash2); }); it("returns different hash for different inputs", () => { const hash1 = hashConversationSeed("seed-1"); const hash2 = hashConversationSeed("seed-2"); expect(hash1).not.toBe(hash2); }); it("handles empty string", () => { const hash = hashConversationSeed(""); expect(hash).toBeTruthy(); }); }); describe("extractTextFromContent", () => { it("extracts text from string content", () => { expect(extractTextFromContent("hello world")).toBe("hello world"); }); it("extracts first text from content array with text blocks", () => { const content = [ { type: "text", text: "hello" }, { type: "text", text: "world" }, ]; expect(extractTextFromContent(content)).toBe("hello"); }); it("returns empty string for non-text blocks", () => { const content = [{ type: "image", source: {} }]; expect(extractTextFromContent(content)).toBe(""); }); it("returns first text block only (not concatenated)", () => { const content = [ { type: "text", text: "before" }, { type: "image", source: {} }, { type: "text", text: "after" }, ]; expect(extractTextFromContent(content)).toBe("before"); }); it("returns empty string for null/undefined", () => { expect(extractTextFromContent(null)).toBe(""); expect(extractTextFromContent(undefined)).toBe(""); }); }); describe("extractConversationSeedFromMessages", () => { it("extracts seed from first user message", () => { const messages = [ { role: "user", content: "first message" }, { role: "assistant", content: "response" }, ]; const seed = extractConversationSeedFromMessages(messages); expect(seed).toContain("first message"); }); it("returns empty string when no user messages", () => { const messages = [{ role: "assistant", content: "response" }]; expect(extractConversationSeedFromMessages(messages)).toBe(""); }); it("handles empty messages array", () => { expect(extractConversationSeedFromMessages([])).toBe(""); }); }); describe("extractConversationSeedFromContents", () => { it("extracts seed from first user content", () => { const contents = [ { role: "user", parts: [{ text: "hello" }] }, { role: "model", parts: [{ text: "hi" }] }, ]; const seed = extractConversationSeedFromContents(contents); expect(seed).toContain("hello"); }); it("returns empty string when no user content", () => { const contents = [{ role: "model", parts: [{ text: "hi" }] }]; expect(extractConversationSeedFromContents(contents)).toBe(""); }); }); describe("resolveProjectKey", () => { it("returns candidate if it is a string", () => { expect(resolveProjectKey("my-project")).toBe("my-project"); }); it("returns fallback if candidate is not a string", () => { expect(resolveProjectKey(null, "fallback")).toBe("fallback"); expect(resolveProjectKey(undefined, "fallback")).toBe("fallback"); expect(resolveProjectKey({}, "fallback")).toBe("fallback"); }); it("returns undefined if no valid candidate or fallback", () => { expect(resolveProjectKey(null)).toBeUndefined(); expect(resolveProjectKey(undefined)).toBeUndefined(); }); }); describe("isGeminiToolUsePart", () => { it("returns true for functionCall parts", () => { expect(isGeminiToolUsePart({ functionCall: { name: "test" } })).toBe(true); }); it("returns false for non-functionCall parts", () => { expect(isGeminiToolUsePart({ text: "hello" })).toBe(false); expect(isGeminiToolUsePart({ thought: true })).toBe(false); }); it("returns false for null/undefined", () => { expect(isGeminiToolUsePart(null)).toBe(false); expect(isGeminiToolUsePart(undefined)).toBe(false); }); }); describe("isGeminiThinkingPart", () => { it("returns true for thought:true parts", () => { expect(isGeminiThinkingPart({ thought: true, text: "thinking..." })).toBe(true); }); it("returns false for thought:false parts", () => { expect(isGeminiThinkingPart({ thought: false, text: "not thinking" })).toBe(false); }); it("returns false for parts without thought property", () => { expect(isGeminiThinkingPart({ text: "hello" })).toBe(false); }); }); describe("ensureThoughtSignature", () => { it("adds sentinel signature when no cached signature exists", () => { const part = { thought: true, text: "thinking..." }; const result = ensureThoughtSignature(part, "no-cache-session"); // Now uses sentinel fallback to prevent API rejection expect(result.thoughtSignature).toBe("skip_thought_signature_validator"); }); it("replaces untrusted thoughtSignature with sentinel", () => { const existingSignature = "a".repeat(MIN_SIGNATURE_LENGTH + 10); const part = { thought: true, text: "thinking...", thoughtSignature: existingSignature }; const result = ensureThoughtSignature(part, "session-key"); expect(result.thoughtSignature).toBe("skip_thought_signature_validator"); }); it("does not modify non-thinking parts", () => { const part = { text: "regular text" }; const result = ensureThoughtSignature(part, "session-key"); expect(result.thoughtSignature).toBeUndefined(); }); it("returns null/undefined inputs unchanged", () => { expect(ensureThoughtSignature(null, "key")).toBeNull(); expect(ensureThoughtSignature(undefined, "key")).toBeUndefined(); }); it("returns non-object inputs unchanged", () => { expect(ensureThoughtSignature("string", "key")).toBe("string"); expect(ensureThoughtSignature(123, "key")).toBe(123); }); }); describe("hasSignedThinkingPart", () => { it("returns true for part with valid thoughtSignature", () => { const part = { thought: true, thoughtSignature: "a".repeat(MIN_SIGNATURE_LENGTH) }; expect(hasSignedThinkingPart(part)).toBe(true); }); it("returns true for type:thinking with valid signature field", () => { const part = { type: "thinking", thinking: "...", signature: "a".repeat(MIN_SIGNATURE_LENGTH) }; expect(hasSignedThinkingPart(part)).toBe(true); }); it("returns true for type:reasoning with valid signature field", () => { const part = { type: "reasoning", signature: "a".repeat(MIN_SIGNATURE_LENGTH) }; expect(hasSignedThinkingPart(part)).toBe(true); }); it("returns false for part with short signature", () => { const part = { thought: true, thoughtSignature: "short" }; expect(hasSignedThinkingPart(part)).toBe(false); }); it("returns false for part without signature", () => { const part = { thought: true, text: "no signature" }; expect(hasSignedThinkingPart(part)).toBe(false); }); }); describe("hasToolUseInContents", () => { it("returns true when contents have functionCall", () => { const contents = [ { role: "model", parts: [{ functionCall: { name: "test" } }] }, ]; expect(hasToolUseInContents(contents)).toBe(true); }); it("returns false when no functionCall present", () => { const contents = [ { role: "model", parts: [{ text: "hello" }] }, ]; expect(hasToolUseInContents(contents)).toBe(false); }); it("handles empty contents", () => { expect(hasToolUseInContents([])).toBe(false); }); }); describe("hasSignedThinkingInContents", () => { it("returns true when contents have signed thinking", () => { const contents = [ { role: "model", parts: [{ thought: true, thoughtSignature: "a".repeat(MIN_SIGNATURE_LENGTH) }], }, ]; expect(hasSignedThinkingInContents(contents)).toBe(true); }); it("returns false when no signed thinking present", () => { const contents = [ { role: "model", parts: [{ thought: true, text: "unsigned" }] }, ]; expect(hasSignedThinkingInContents(contents)).toBe(false); }); }); describe("hasToolUseInMessages", () => { it("returns true when messages have tool_use blocks", () => { const messages = [ { role: "assistant", content: [{ type: "tool_use", id: "123", name: "test" }] }, ]; expect(hasToolUseInMessages(messages)).toBe(true); }); it("returns false when no tool_use blocks", () => { const messages = [ { role: "assistant", content: [{ type: "text", text: "hello" }] }, ]; expect(hasToolUseInMessages(messages)).toBe(false); }); it("handles string content", () => { const messages = [{ role: "assistant", content: "just text" }]; expect(hasToolUseInMessages(messages)).toBe(false); }); }); describe("hasSignedThinkingInMessages", () => { it("returns true when messages have signed thinking blocks", () => { const messages = [ { role: "assistant", content: [{ type: "thinking", thinking: "...", signature: "a".repeat(MIN_SIGNATURE_LENGTH) }], }, ]; expect(hasSignedThinkingInMessages(messages)).toBe(true); }); it("returns false when thinking blocks are unsigned", () => { const messages = [ { role: "assistant", content: [{ type: "thinking", thinking: "no sig" }] }, ]; expect(hasSignedThinkingInMessages(messages)).toBe(false); }); }); describe("generateSyntheticProjectId", () => { it("generates a string in expected format", () => { const id = generateSyntheticProjectId(); expect(id).toMatch(/^[a-z]+-[a-z]+-[a-z0-9]{5}$/); }); it("generates unique IDs on each call", () => { const ids = new Set(); for (let i = 0; i < 10; i++) { ids.add(generateSyntheticProjectId()); } expect(ids.size).toBe(10); }); }); describe("MIN_SIGNATURE_LENGTH", () => { it("is 50", () => { expect(MIN_SIGNATURE_LENGTH).toBe(50); }); }); describe("transformSseLine", () => { const callTransformSseLine = (line: string) => { const store = createMockSignatureStore(); const buffer = createMockThoughtBuffer(); const sentBuffer = createMockThoughtBuffer(); return transformSseLine(line, store, buffer, sentBuffer, defaultCallbacks, defaultOptions, { ...defaultDebugState }); }; it("returns empty lines unchanged", () => { expect(callTransformSseLine("")).toBe(""); expect(callTransformSseLine(" ")).toBe(" "); }); it("returns non-data lines unchanged", () => { expect(callTransformSseLine("event: message")).toBe("event: message"); expect(callTransformSseLine(": heartbeat")).toBe(": heartbeat"); }); it("handles data: [DONE] unchanged", () => { expect(callTransformSseLine("data: [DONE]")).toBe("data: [DONE]"); }); it("handles invalid JSON gracefully", () => { expect(callTransformSseLine("data: not-json")).toBe("data: not-json"); expect(callTransformSseLine("data: {invalid}")).toBe("data: {invalid}"); }); it("passes through valid JSON without thinking parts", () => { const payload = { candidates: [{ content: { parts: [{ text: "hello" }] } }] }; const line = `data: ${JSON.stringify(payload)}`; const result = callTransformSseLine(line); expect(result).toContain("data:"); expect(result).toContain("hello"); }); it("transforms thinking parts in streaming data", () => { const payload = { candidates: [{ content: { parts: [{ thought: true, text: "reasoning..." }] } }] }; const line = `data: ${JSON.stringify(payload)}`; const result = callTransformSseLine(line); expect(result).toContain("data:"); }); }); describe("transformStreamingPayload", () => { it("handles empty string", () => { expect(transformStreamingPayload("")).toBe(""); }); it("handles single line without data prefix", () => { expect(transformStreamingPayload("event: ping")).toBe("event: ping"); }); it("handles multiple lines", () => { const input = "event: message\ndata: [DONE]\n"; const result = transformStreamingPayload(input); expect(result).toContain("event: message"); expect(result).toContain("data: [DONE]"); }); it("preserves line structure", () => { const input = "line1\nline2\nline3"; const result = transformStreamingPayload(input); const lines = result.split("\n"); expect(lines.length).toBe(3); }); }); describe("createStreamingTransformer", () => { it("returns a TransformStream", () => { const store = createMockSignatureStore(); const transformer = createStreamingTransformer(store, defaultCallbacks); expect(transformer).toBeInstanceOf(TransformStream); expect(transformer.readable).toBeDefined(); expect(transformer.writable).toBeDefined(); }); it("accepts optional signatureSessionKey", () => { const store = createMockSignatureStore(); const transformer = createStreamingTransformer(store, defaultCallbacks, { signatureSessionKey: "session-key" }); expect(transformer).toBeInstanceOf(TransformStream); }); it("accepts optional debugText", () => { const store = createMockSignatureStore(); const transformer = createStreamingTransformer(store, defaultCallbacks, { signatureSessionKey: "session-key", debugText: "debug info" }); expect(transformer).toBeInstanceOf(TransformStream); }); it("accepts cacheSignatures flag", () => { const store = createMockSignatureStore(); const transformer = createStreamingTransformer(store, defaultCallbacks, { signatureSessionKey: "session-key", cacheSignatures: true }); expect(transformer).toBeInstanceOf(TransformStream); }); it("processes chunks through the stream", async () => { const store = createMockSignatureStore(); const transformer = createStreamingTransformer(store, defaultCallbacks); const encoder = new TextEncoder(); const decoder = new TextDecoder(); const input = encoder.encode("data: [DONE]\n"); const outputChunks: Uint8Array[] = []; const writer = transformer.writable.getWriter(); const reader = transformer.readable.getReader(); const readPromise = (async () => { while (true) { const { done, value } = await reader.read(); if (done) break; if (value) outputChunks.push(value); } })(); await writer.write(input); await writer.close(); await readPromise; const output = outputChunks.map(chunk => decoder.decode(chunk)).join(""); expect(output).toContain("[DONE]"); }); }); describe("prepareAntigravityRequest", () => { const mockAccessToken = "test-token"; const mockProjectId = "test-project"; it("returns unchanged request for non-generative-language URLs", () => { const result = prepareAntigravityRequest( "https://example.com/api", { method: "POST" }, mockAccessToken, mockProjectId ); expect(result.streaming).toBe(false); expect(result.request).toBe("https://example.com/api"); }); it("returns unchanged request for URLs without model pattern", () => { const result = prepareAntigravityRequest( "https://generativelanguage.googleapis.com/v1/models", { method: "POST" }, mockAccessToken, mockProjectId ); expect(result.streaming).toBe(false); }); it("detects streaming from generateStreamContent action", () => { const result = prepareAntigravityRequest( "https://generativelanguage.googleapis.com/v1beta/models/gemini-pro:streamGenerateContent", { method: "POST", body: JSON.stringify({ contents: [] }) }, mockAccessToken, mockProjectId ); expect(result.streaming).toBe(true); }); it("detects non-streaming from generateContent action", () => { const result = prepareAntigravityRequest( "https://generativelanguage.googleapis.com/v1beta/models/gemini-pro:generateContent", { method: "POST", body: JSON.stringify({ contents: [] }) }, mockAccessToken, mockProjectId ); expect(result.streaming).toBe(false); }); it("sets Authorization header with Bearer token", () => { const result = prepareAntigravityRequest( "https://generativelanguage.googleapis.com/v1beta/models/gemini-pro:generateContent", { method: "POST", body: JSON.stringify({ contents: [] }) }, mockAccessToken, mockProjectId ); const headers = result.init.headers as Headers; expect(headers.get("Authorization")).toBe("Bearer test-token"); }); it("removes x-api-key header", () => { const result = prepareAntigravityRequest( "https://generativelanguage.googleapis.com/v1beta/models/gemini-pro:generateContent", { method: "POST", body: JSON.stringify({ contents: [] }), headers: { "x-api-key": "old-key" } }, mockAccessToken, mockProjectId ); const headers = result.init.headers as Headers; expect(headers.get("x-api-key")).toBeNull(); }); it("removes x-goog-user-project header for antigravity headerStyle", () => { const result = prepareAntigravityRequest( "https://generativelanguage.googleapis.com/v1beta/models/claude-opus-4-6-thinking:generateContent", { method: "POST", body: JSON.stringify({ contents: [] }), headers: { "x-goog-user-project": "my-project" } }, mockAccessToken, mockProjectId, undefined, "antigravity" ); const headers = result.init.headers as Headers; expect(headers.get("x-goog-user-project")).toBeNull(); }); it("removes x-goog-user-project header for gemini-cli headerStyle", () => { const result = prepareAntigravityRequest( "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent", { method: "POST", body: JSON.stringify({ contents: [] }), headers: { "x-goog-user-project": "my-project" } }, mockAccessToken, mockProjectId, undefined, "gemini-cli" ); const headers = result.init.headers as Headers; expect(headers.get("x-goog-user-project")).toBeNull(); }); it("uses exact Code Assist headers for gemini-cli headerStyle", () => { const result = prepareAntigravityRequest( "https://generativelanguage.googleapis.com/v1beta/models/gemini-3-flash:generateContent", { method: "POST", body: JSON.stringify({ contents: [] }) }, mockAccessToken, mockProjectId, undefined, "gemini-cli" ); const headers = result.init.headers as Headers; expect(headers.get("User-Agent")).toBe("google-api-nodejs-client/9.15.1"); expect(headers.get("X-Goog-Api-Client")).toBe("gl-node/22.17.0"); expect(headers.get("Client-Metadata")).toBe("ideType=IDE_UNSPECIFIED,platform=PLATFORM_UNSPECIFIED,pluginType=GEMINI"); }); it("builds gemini-cli wrapped body without antigravity-only fields", () => { const result = prepareAntigravityRequest( "https://generativelanguage.googleapis.com/v1beta/models/gemini-3-flash:generateContent", { method: "POST", body: JSON.stringify({ contents: [{ role: "user", parts: [{ text: "hi" }] }] }) }, mockAccessToken, "", undefined, "gemini-cli" ); const parsed = JSON.parse(result.init.body as string); expect(parsed).toHaveProperty("project", ""); expect(parsed).toHaveProperty("model"); expect(parsed).toHaveProperty("request"); expect(parsed.requestType).toBeUndefined(); expect(parsed.userAgent).toBeUndefined(); expect(parsed.requestId).toBeUndefined(); }); it("identifies Claude models correctly", () => { const result = prepareAntigravityRequest( "https://generativelanguage.googleapis.com/v1beta/models/claude-sonnet-4-20250514:generateContent", { method: "POST", body: JSON.stringify({ contents: [] }) }, mockAccessToken, mockProjectId ); expect(result.effectiveModel).toContain("claude"); }); it("identifies Gemini models correctly", () => { const result = prepareAntigravityRequest( "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-pro:generateContent", { method: "POST", body: JSON.stringify({ contents: [] }) }, mockAccessToken, mockProjectId ); expect(result.effectiveModel).toContain("gemini"); }); it("uses custom endpoint override", () => { const customEndpoint = "https://custom.api.com"; const result = prepareAntigravityRequest( "https://generativelanguage.googleapis.com/v1beta/models/gemini-pro:generateContent", { method: "POST", body: JSON.stringify({ contents: [] }) }, mockAccessToken, mockProjectId, customEndpoint ); expect(result.endpoint).toContain(customEndpoint); }); it("handles wrapped Antigravity body format", () => { const wrappedBody = { project: "my-project", request: { contents: [{ parts: [{ text: "Hello" }] }] } }; const result = prepareAntigravityRequest( "https://generativelanguage.googleapis.com/v1beta/models/gemini-pro:generateContent", { method: "POST", body: JSON.stringify(wrappedBody) }, mockAccessToken, mockProjectId ); expect(result.streaming).toBe(false); }); it("handles unwrapped body format", () => { const unwrappedBody = { contents: [{ parts: [{ text: "Hello" }] }] }; const result = prepareAntigravityRequest( "https://generativelanguage.googleapis.com/v1beta/models/gemini-pro:generateContent", { method: "POST", body: JSON.stringify(unwrappedBody) }, mockAccessToken, mockProjectId ); expect(result.streaming).toBe(false); }); it("does not add Claude auto-caching to wrapped request by default", () => { const wrappedBody = { project: "my-project", request: { messages: [{ role: "user", content: [{ type: "text", text: "Hello" }] }] } }; const result = prepareAntigravityRequest( "https://generativelanguage.googleapis.com/v1beta/models/claude-3-7-sonnet:generateContent", { method: "POST", body: JSON.stringify(wrappedBody) }, mockAccessToken, mockProjectId, ); const wrapped = JSON.parse(result.init.body as string); expect(wrapped.request.cache_control).toBeUndefined(); }); it("does not add Claude auto-caching to unwrapped request by default", () => { const unwrappedBody = { messages: [{ role: "user", content: [{ type: "text", text: "Hello" }] }] }; const result = prepareAntigravityRequest( "https://generativelanguage.googleapis.com/v1beta/models/claude-3-7-sonnet:generateContent", { method: "POST", body: JSON.stringify(unwrappedBody) }, mockAccessToken, mockProjectId, ); const wrapped = JSON.parse(result.init.body as string); expect(wrapped.request.cache_control).toBeUndefined(); }); it("adds Claude auto-caching when enabled", () => { const unwrappedBody = { messages: [{ role: "user", content: [{ type: "text", text: "Hello" }] }] }; const result = prepareAntigravityRequest( "https://generativelanguage.googleapis.com/v1beta/models/claude-3-7-sonnet:generateContent", { method: "POST", body: JSON.stringify(unwrappedBody) }, mockAccessToken, mockProjectId, undefined, "antigravity", false, { claudePromptAutoCaching: true }, ); const wrapped = JSON.parse(result.init.body as string); expect(wrapped.request.cache_control).toEqual({ type: "ephemeral" }); }); it("strips Claude thinking blocks when keep_thinking is false (unwrapped)", () => { const result = withKeepThinking(false, () => prepareAntigravityRequest( "https://generativelanguage.googleapis.com/v1beta/models/claude-opus-4-6-thinking:generateContent", { method: "POST", body: JSON.stringify({ contents: [ { role: "model", parts: [ { thought: true, text: "foreign-thought-unwrapped", thoughtSignature: "f".repeat(MIN_SIGNATURE_LENGTH + 8), }, { functionCall: { name: "weather", args: {} } }, ], }, ], }), }, mockAccessToken, mockProjectId, )); const wrapped = JSON.parse(result.init.body as string); const parts = wrapped.request.contents[0].parts as Array>; const thinkingParts = parts.filter((part) => part.thought === true || part.type === "thinking" || part.type === "redacted_thinking" || part.type === "reasoning", ); expect(thinkingParts).toHaveLength(0); expect(result.needsSignedThinkingWarmup).toBe(false); }); it("strips Claude thinking blocks when keep_thinking is false (wrapped)", () => { const result = withKeepThinking(false, () => prepareAntigravityRequest( "https://generativelanguage.googleapis.com/v1beta/models/claude-opus-4-6-thinking:generateContent", { method: "POST", body: JSON.stringify({ project: "my-project", request: { contents: [ { role: "model", parts: [ { thought: true, text: "foreign-thought-wrapped", thoughtSignature: "w".repeat(MIN_SIGNATURE_LENGTH + 8), }, { functionCall: { name: "weather", args: {} } }, ], }, ], }, }), }, mockAccessToken, mockProjectId, )); const wrapped = JSON.parse(result.init.body as string); const parts = wrapped.request.contents[0].parts as Array>; const thinkingParts = parts.filter((part) => part.thought === true || part.type === "thinking" || part.type === "redacted_thinking" || part.type === "reasoning", ); expect(thinkingParts).toHaveLength(0); expect(result.needsSignedThinkingWarmup).toBe(false); }); it("does not trust foreign Gemini thoughtSignature when keep_thinking is true", () => { const foreignSignature = "x".repeat(MIN_SIGNATURE_LENGTH + 8); const result = withKeepThinking(true, () => prepareAntigravityRequest( "https://generativelanguage.googleapis.com/v1beta/models/claude-opus-4-6-thinking:generateContent", { method: "POST", body: JSON.stringify({ contents: [ { role: "model", parts: [ { thought: true, text: "foreign-thought-keep-true", thoughtSignature: foreignSignature, }, { functionCall: { name: "weather", args: {} } }, ], }, ], }), }, mockAccessToken, mockProjectId, )); const wrapped = JSON.parse(result.init.body as string); const parts = wrapped.request.contents[0].parts as Array>; const thinkingBlock = parts.find((part) => part.thought === true || part.type === "thinking" || part.type === "redacted_thinking", ); const signature = typeof thinkingBlock?.signature === "string" ? thinkingBlock.signature : thinkingBlock?.thoughtSignature; expect(JSON.stringify(wrapped)).not.toContain(foreignSignature); if (thinkingBlock) { expect(signature).toBe(SKIP_THOUGHT_SIGNATURE); } }); it("replaces foreign Claude signatures with sentinel when keep_thinking is true", () => { const foreignSignature = "y".repeat(MIN_SIGNATURE_LENGTH + 8); const result = withKeepThinking(true, () => prepareAntigravityRequest( "https://generativelanguage.googleapis.com/v1beta/models/claude-opus-4-6-thinking:generateContent", { method: "POST", body: JSON.stringify({ messages: [ { role: "assistant", content: [ { type: "thinking", thinking: "foreign-message-thinking", signature: foreignSignature, }, { type: "tool_use", id: "tool-1", name: "weather", input: {}, }, ], }, ], }), }, mockAccessToken, mockProjectId, )); const wrapped = JSON.parse(result.init.body as string); const content = wrapped.request.messages[0].content as Array>; const thinkingBlock = content.find((block) => block.type === "thinking" || block.type === "redacted_thinking"); expect(thinkingBlock).toBeTruthy(); expect(thinkingBlock?.signature).toBe(SKIP_THOUGHT_SIGNATURE); expect(JSON.stringify(content)).not.toContain(foreignSignature); expect(result.needsSignedThinkingWarmup).toBe(false); }); it("returns requestedModel matching URL model", () => { const result = prepareAntigravityRequest( "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent", { method: "POST", body: JSON.stringify({ contents: [] }) }, mockAccessToken, mockProjectId ); expect(result.requestedModel).toBe("gemini-2.5-flash"); }); it("handles empty body gracefully", () => { const result = prepareAntigravityRequest( "https://generativelanguage.googleapis.com/v1beta/models/gemini-pro:generateContent", { method: "POST", body: JSON.stringify({}) }, mockAccessToken, mockProjectId ); expect(result.streaming).toBe(false); }); it("handles minimal valid JSON body", () => { const result = prepareAntigravityRequest( "https://generativelanguage.googleapis.com/v1beta/models/gemini-pro:generateContent", { method: "POST", body: JSON.stringify({ contents: [] }) }, mockAccessToken, mockProjectId ); expect(result.streaming).toBe(false); }); it("removes contents entries with empty or invalid parts", () => { const result = prepareAntigravityRequest( "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent", { method: "POST", body: JSON.stringify({ contents: [ { role: "user", parts: [] }, { role: "model", parts: [null, { text: "kept" }] }, { role: "user", parts: null }, ], systemInstruction: { role: "user", parts: [null, { text: "system kept" }], }, }), }, mockAccessToken, mockProjectId, undefined, "gemini-cli", ); const wrapped = JSON.parse(result.init.body as string); expect(wrapped.request.contents).toHaveLength(1); expect(wrapped.request.contents[0]).toEqual({ role: "model", parts: [{ text: "kept" }], }); expect(wrapped.request.systemInstruction.parts).toEqual([{ text: "system kept" }]); }); it("drops systemInstruction when all parts are invalid", () => { const result = prepareAntigravityRequest( "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent", { method: "POST", body: JSON.stringify({ contents: [{ role: "user", parts: [{ text: "hi" }] }], systemInstruction: { role: "user", parts: [null], }, }), }, mockAccessToken, mockProjectId, undefined, "gemini-cli", ); const wrapped = JSON.parse(result.init.body as string); expect(wrapped.request.systemInstruction).toBeUndefined(); }); it("preserves headerStyle in response", () => { const result = prepareAntigravityRequest( "https://generativelanguage.googleapis.com/v1beta/models/gemini-pro:generateContent", { method: "POST", body: JSON.stringify({ contents: [] }) }, mockAccessToken, mockProjectId, undefined, "gemini-cli" ); expect(result.headerStyle).toBe("gemini-cli"); }); describe("Issue #103: model name transformation during quota fallback", () => { it("transforms gemini-3-flash-preview to gemini-3-flash for antigravity headerStyle", () => { const result = prepareAntigravityRequest( "https://generativelanguage.googleapis.com/v1beta/models/gemini-3-flash-preview:generateContent", { method: "POST", body: JSON.stringify({ contents: [] }) }, mockAccessToken, mockProjectId, undefined, "antigravity" ); expect(result.effectiveModel).toBe("gemini-3-flash"); }); it("transforms gemini-3-pro-preview to gemini-3-pro-low for antigravity headerStyle", () => { const result = prepareAntigravityRequest( "https://generativelanguage.googleapis.com/v1beta/models/gemini-3-pro-preview:generateContent", { method: "POST", body: JSON.stringify({ contents: [] }) }, mockAccessToken, mockProjectId, undefined, "antigravity" ); expect(result.effectiveModel).toBe("gemini-3-pro-low"); }); it("transforms gemini-3.1-pro-preview to gemini-3.1-pro-low for antigravity headerStyle", () => { const result = prepareAntigravityRequest( "https://generativelanguage.googleapis.com/v1beta/models/gemini-3.1-pro-preview:generateContent", { method: "POST", body: JSON.stringify({ contents: [] }) }, mockAccessToken, mockProjectId, undefined, "antigravity" ); expect(result.effectiveModel).toBe("gemini-3.1-pro-low"); }); it("transforms gemini-3.1-pro-preview-customtools to gemini-3.1-pro-low for antigravity headerStyle", () => { const result = prepareAntigravityRequest( "https://generativelanguage.googleapis.com/v1beta/models/gemini-3.1-pro-preview-customtools:generateContent", { method: "POST", body: JSON.stringify({ contents: [] }) }, mockAccessToken, mockProjectId, undefined, "antigravity" ); expect(result.effectiveModel).toBe("gemini-3.1-pro-low"); }); it("transforms gemini-3-flash to gemini-3-flash-preview for gemini-cli headerStyle", () => { const result = prepareAntigravityRequest( "https://generativelanguage.googleapis.com/v1beta/models/gemini-3-flash:generateContent", { method: "POST", body: JSON.stringify({ contents: [] }) }, mockAccessToken, mockProjectId, undefined, "gemini-cli" ); expect(result.effectiveModel).toBe("gemini-3-flash-preview"); }); it("transforms gemini-3-pro-low to gemini-3-pro-preview for gemini-cli headerStyle", () => { const result = prepareAntigravityRequest( "https://generativelanguage.googleapis.com/v1beta/models/gemini-3-pro-low:generateContent", { method: "POST", body: JSON.stringify({ contents: [] }) }, mockAccessToken, mockProjectId, undefined, "gemini-cli" ); expect(result.effectiveModel).toBe("gemini-3-pro-preview"); }); it("transforms gemini-3.1-pro-low to gemini-3.1-pro-preview for gemini-cli headerStyle", () => { const result = prepareAntigravityRequest( "https://generativelanguage.googleapis.com/v1beta/models/gemini-3.1-pro-low:generateContent", { method: "POST", body: JSON.stringify({ contents: [] }) }, mockAccessToken, mockProjectId, undefined, "gemini-cli" ); expect(result.effectiveModel).toBe("gemini-3.1-pro-preview"); }); it("keeps gemini-3.1-pro-preview-customtools unchanged for gemini-cli headerStyle", () => { const result = prepareAntigravityRequest( "https://generativelanguage.googleapis.com/v1beta/models/gemini-3.1-pro-preview-customtools:generateContent", { method: "POST", body: JSON.stringify({ contents: [] }) }, mockAccessToken, mockProjectId, undefined, "gemini-cli" ); expect(result.effectiveModel).toBe("gemini-3.1-pro-preview-customtools"); }); it("keeps non-Gemini-3 models unchanged regardless of headerStyle", () => { const result = prepareAntigravityRequest( "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent", { method: "POST", body: JSON.stringify({ contents: [] }) }, mockAccessToken, mockProjectId, undefined, "antigravity" ); expect(result.effectiveModel).toBe("gemini-2.5-flash"); }); }); }); describe("transformAntigravityResponse", () => { it("injects [ThinkingResolution] details when debug_tui is enabled", async () => { initializeDebug({ ...DEFAULT_CONFIG, debug: false, debug_tui: true, }); const response = new Response( JSON.stringify({ error: { code: 500, message: "Upstream error", status: "INTERNAL", }, }), { status: 500, headers: { "content-type": "application/json" }, }, ); const transformed = await transformAntigravityResponse( response, false, undefined, "gemini-2.5-pro", "test-project", "https://daily-cloudcode-pa.sandbox.googleapis.com/v1internal:generateContent", "gemini-2.5-pro", "session-1", 0, "summary", undefined, [ "status=500 INTERNAL", "endpoint=https://daily-cloudcode-pa.sandbox.googleapis.com/v1internal:generateContent", "account=test@example.com", ], ); const bodyText = await transformed.text(); expect(bodyText).toContain("[ThinkingResolution]"); expect(bodyText).toContain("status=500 INTERNAL"); expect(bodyText).toContain("endpoint=https://daily-cloudcode-pa.sandbox.googleapis.com/v1internal:generateContent"); expect(bodyText).toContain("account=test@example.com"); initializeDebug(DEFAULT_CONFIG); }); it("does not misclassify generic INVALID_ARGUMENT as thinking recovery from debug metadata", async () => { const response = new Response( JSON.stringify({ error: { code: 400, message: "Request contains an invalid argument.", status: "INVALID_ARGUMENT", }, }), { status: 400, headers: { "content-type": "application/json" }, }, ); const transformed = await transformAntigravityResponse( response, true, undefined, "antigravity-claude-opus-4-6-thinking", "test-project", "https://daily-cloudcode-pa.sandbox.googleapis.com/v1internal:streamGenerateContent?alt=sse", "claude-opus-4-6-thinking", "session-1", 0, "expected=1 found=0", ); await expect(transformed.text()).resolves.toContain("Request contains an invalid argument."); }); it("rethrows THINKING_RECOVERY_NEEDED for outer retry handling", async () => { const response = new Response( JSON.stringify({ error: { code: 400, message: "Thinking must start with a thinking block before tool use.", status: "INVALID_ARGUMENT", }, }), { status: 400, headers: { "content-type": "application/json" }, }, ); await expect( transformAntigravityResponse( response, true, undefined, "antigravity-claude-opus-4-6-thinking", "test-project", "https://daily-cloudcode-pa.sandbox.googleapis.com/v1internal:streamGenerateContent?alt=sse", "claude-opus-4-6-thinking", "session-1", ), ).rejects.toMatchObject({ message: "THINKING_RECOVERY_NEEDED" }); }); }); }); ================================================ FILE: src/plugin/request.ts ================================================ import crypto from "node:crypto"; import { ANTIGRAVITY_ENDPOINT, GEMINI_CLI_ENDPOINT, GEMINI_CLI_HEADERS, EMPTY_SCHEMA_PLACEHOLDER_NAME, EMPTY_SCHEMA_PLACEHOLDER_DESCRIPTION, SKIP_THOUGHT_SIGNATURE, getRandomizedHeaders, type HeaderStyle, } from "../constants"; import { cacheSignature, getCachedSignature } from "./cache"; import { getKeepThinking } from "./config"; import { createStreamingTransformer, transformSseLine, transformStreamingPayload, } from "./core/streaming"; import { defaultSignatureStore } from "./stores/signature-store"; import { DEBUG_MESSAGE_PREFIX, isDebugEnabled, isDebugTuiEnabled, logAntigravityDebugResponse, logCacheStats, type AntigravityDebugContext, } from "./debug"; import { createLogger } from "./logger"; import { cleanJSONSchemaForAntigravity, DEFAULT_THINKING_BUDGET, deepFilterThinkingBlocks, extractThinkingConfig, extractVariantThinkingConfig, extractUsageFromSsePayload, extractUsageMetadata, fixToolResponseGrouping, validateAndFixClaudeToolPairing, applyToolPairingFixes, injectParameterSignatures, injectToolHardeningInstruction, isThinkingCapableModel, normalizeThinkingConfig, parseAntigravityApiBody, resolveThinkingConfig, rewriteAntigravityPreviewAccessError, transformThinkingParts, type AntigravityApiBody, } from "./request-helpers"; import { CLAUDE_TOOL_SYSTEM_INSTRUCTION, CLAUDE_DESCRIPTION_PROMPT, ANTIGRAVITY_SYSTEM_INSTRUCTION, } from "../constants"; import { analyzeConversationState, closeToolLoopForThinking, needsThinkingRecovery, } from "./thinking-recovery"; import { sanitizeCrossModelPayloadInPlace } from "./transform/cross-model-sanitizer"; import { isGemini3Model, isImageGenerationModel, buildImageGenerationConfig, applyGeminiTransforms } from "./transform"; import { resolveModelWithTier, resolveModelWithVariant, resolveModelForHeaderStyle, isClaudeModel, isClaudeThinkingModel, CLAUDE_THINKING_MAX_OUTPUT_TOKENS, type ThinkingTier, } from "./transform"; import { detectErrorType } from "./recovery"; import { getSessionFingerprint, buildFingerprintHeaders, type Fingerprint } from "./fingerprint"; import type { GoogleSearchConfig } from "./transform/types"; const log = createLogger("request"); const PLUGIN_SESSION_ID = `-${crypto.randomUUID()}`; const sessionDisplayedThinkingHashes = new Set(); const MIN_SIGNATURE_LENGTH = 50; function buildSignatureSessionKey( sessionId: string, model?: string, conversationKey?: string, projectKey?: string, ): string { const modelKey = typeof model === "string" && model.trim() ? model.toLowerCase() : "unknown"; const projectPart = typeof projectKey === "string" && projectKey.trim() ? projectKey.trim() : "default"; const conversationPart = typeof conversationKey === "string" && conversationKey.trim() ? conversationKey.trim() : "default"; return `${sessionId}:${modelKey}:${projectPart}:${conversationPart}`; } function shouldCacheThinkingSignatures(model?: string): boolean { if (typeof model !== "string") return false; const lower = model.toLowerCase(); // Both Claude and Gemini 3 models require thought signature caching // for multi-turn conversations with function calling return lower.includes("claude") || lower.includes("gemini-3"); } function hashConversationSeed(seed: string): string { return crypto.createHash("sha256").update(seed, "utf8").digest("hex").slice(0, 16); } function extractTextFromContent(content: unknown): string { if (typeof content === "string") { return content; } if (!Array.isArray(content)) { return ""; } for (const block of content) { if (!block || typeof block !== "object") { continue; } const anyBlock = block as any; if (typeof anyBlock.text === "string") { return anyBlock.text; } if (anyBlock.text && typeof anyBlock.text === "object" && typeof anyBlock.text.text === "string") { return anyBlock.text.text; } } return ""; } function extractConversationSeedFromMessages(messages: any[]): string { const system = messages.find((message) => message?.role === "system"); const users = messages.filter((message) => message?.role === "user"); const firstUser = users[0]; const lastUser = users.length > 0 ? users[users.length - 1] : undefined; const systemText = system ? extractTextFromContent(system.content) : ""; const userText = firstUser ? extractTextFromContent(firstUser.content) : ""; const fallbackUserText = !userText && lastUser ? extractTextFromContent(lastUser.content) : ""; return [systemText, userText || fallbackUserText].filter(Boolean).join("|"); } function extractConversationSeedFromContents(contents: any[]): string { const users = contents.filter((content) => content?.role === "user"); const firstUser = users[0]; const lastUser = users.length > 0 ? users[users.length - 1] : undefined; const primaryUser = firstUser && Array.isArray(firstUser.parts) ? extractTextFromContent(firstUser.parts) : ""; if (primaryUser) { return primaryUser; } if (lastUser && Array.isArray(lastUser.parts)) { return extractTextFromContent(lastUser.parts); } return ""; } function resolveConversationKey(requestPayload: Record): string | undefined { const anyPayload = requestPayload as any; const candidates = [ anyPayload.conversationId, anyPayload.conversation_id, anyPayload.thread_id, anyPayload.threadId, anyPayload.chat_id, anyPayload.chatId, anyPayload.sessionId, anyPayload.session_id, anyPayload.metadata?.conversation_id, anyPayload.metadata?.conversationId, anyPayload.metadata?.thread_id, anyPayload.metadata?.threadId, ]; for (const candidate of candidates) { if (typeof candidate === "string" && candidate.trim()) { return candidate.trim(); } } const systemSeed = extractTextFromContent( (anyPayload.systemInstruction as any)?.parts ?? anyPayload.systemInstruction ?? anyPayload.system ?? anyPayload.system_instruction, ); const messageSeed = Array.isArray(anyPayload.messages) ? extractConversationSeedFromMessages(anyPayload.messages) : Array.isArray(anyPayload.contents) ? extractConversationSeedFromContents(anyPayload.contents) : ""; const seed = [systemSeed, messageSeed].filter(Boolean).join("|"); if (!seed) { return undefined; } return `seed-${hashConversationSeed(seed)}`; } function resolveConversationKeyFromRequests(requestObjects: Array>): string | undefined { for (const req of requestObjects) { const key = resolveConversationKey(req); if (key) { return key; } } return undefined; } function resolveProjectKey(candidate?: unknown, fallback?: string): string | undefined { if (typeof candidate === "string" && candidate.trim()) { return candidate.trim(); } if (typeof fallback === "string" && fallback.trim()) { return fallback.trim(); } return undefined; } function formatDebugLinesForThinking(lines: string[]): string { const cleaned = lines .map((line) => line.trim()) .filter((line) => line.length > 0) .slice(-50); const prelude = `[ThinkingResolution] source=debug_tui lines=${cleaned.length}`; return `${DEBUG_MESSAGE_PREFIX}\n- ${prelude}\n${cleaned.map((line) => `- ${line}`).join("\n")}`; } function injectDebugThinking(response: unknown, debugText: string): unknown { if (!response || typeof response !== "object") { return response; } const resp = response as any; if (Array.isArray(resp.candidates) && resp.candidates.length > 0) { const candidates = resp.candidates.slice(); const first = candidates[0]; if ( first && typeof first === "object" && first.content && typeof first.content === "object" && Array.isArray(first.content.parts) ) { const parts = [{ thought: true, text: debugText }, ...first.content.parts]; candidates[0] = { ...first, content: { ...first.content, parts } }; return { ...resp, candidates }; } return resp; } if (Array.isArray(resp.content)) { const content = [{ type: "thinking", thinking: debugText }, ...resp.content]; return { ...resp, content }; } if (!resp.reasoning_content) { return { ...resp, reasoning_content: debugText }; } return resp; } /** * Synthetic thinking placeholder text used when keep_thinking=true but debug mode is off. * Injected via the same path as debug text (injectDebugThinking) to ensure consistent * signature caching and multi-turn handling. */ const SYNTHETIC_THINKING_PLACEHOLDER = "[Thinking preserved]\n"; function stripInjectedDebugFromParts(parts: unknown): unknown { if (!Array.isArray(parts)) { return parts; } return parts.filter((part) => { if (!part || typeof part !== "object") { return true; } const record = part as any; const text = typeof record.text === "string" ? record.text : typeof record.thinking === "string" ? record.thinking : undefined; // Strip debug blocks and synthetic thinking placeholders if (text && (text.startsWith(DEBUG_MESSAGE_PREFIX) || text.startsWith(SYNTHETIC_THINKING_PLACEHOLDER.trim()))) { return false; } return true; }); } function stripInjectedDebugFromRequestPayload(payload: Record): void { const anyPayload = payload as any; if (Array.isArray(anyPayload.contents)) { anyPayload.contents = anyPayload.contents.map((content: any) => { if (!content || typeof content !== "object") { return content; } if (Array.isArray(content.parts)) { return { ...content, parts: stripInjectedDebugFromParts(content.parts) }; } if (Array.isArray(content.content)) { return { ...content, content: stripInjectedDebugFromParts(content.content) }; } return content; }); } if (Array.isArray(anyPayload.messages)) { anyPayload.messages = anyPayload.messages.map((message: any) => { if (!message || typeof message !== "object") { return message; } if (Array.isArray(message.content)) { return { ...message, content: stripInjectedDebugFromParts(message.content) }; } return message; }); } } function isValidRequestPart(part: unknown): boolean { if (!part || typeof part !== "object") { return false; } const record = part as Record; return ( Object.prototype.hasOwnProperty.call(record, "text") || Object.prototype.hasOwnProperty.call(record, "functionCall") || Object.prototype.hasOwnProperty.call(record, "functionResponse") || Object.prototype.hasOwnProperty.call(record, "inlineData") || Object.prototype.hasOwnProperty.call(record, "fileData") || Object.prototype.hasOwnProperty.call(record, "executableCode") || Object.prototype.hasOwnProperty.call(record, "codeExecutionResult") || Object.prototype.hasOwnProperty.call(record, "thought") ); } function sanitizeRequestPayloadForAntigravity(payload: Record): void { const anyPayload = payload as any; if (Array.isArray(anyPayload.contents)) { anyPayload.contents = anyPayload.contents .map((content: unknown) => { if (!content || typeof content !== "object") { return null; } const contentRecord = content as Record; const rawParts = Array.isArray(contentRecord.parts) ? contentRecord.parts : []; let foundFirstFunctionCall = false; const sanitizedParts = rawParts.filter(isValidRequestPart).map((part: any) => { if (part && typeof part === "object" && part.functionCall) { let sig = part.thoughtSignature || part.thought_signature; // Only the first functionCall part in a block should have the signature. // If it's the first one and missing a valid signature, inject the sentinel // to prevent the API from rejecting the request with a 400 error. if (!foundFirstFunctionCall) { foundFirstFunctionCall = true; if (!sig || sig.length < MIN_SIGNATURE_LENGTH) { sig = SKIP_THOUGHT_SIGNATURE; } } else { // Parallel function calls MUST NOT have a signature sig = undefined; } if (sig) { return { ...part, thought_signature: sig, thoughtSignature: sig }; } // If not the first part, just return the part without adding any signature keys const newPart = { ...part }; delete newPart.thoughtSignature; delete newPart.thought_signature; return newPart; } return part; }); if (sanitizedParts.length === 0) { return null; } return { ...contentRecord, parts: sanitizedParts, }; }) .filter((content: unknown): content is Record => content !== null); } const systemInstruction = anyPayload.systemInstruction; if (systemInstruction && typeof systemInstruction === "object" && !Array.isArray(systemInstruction)) { const sys = systemInstruction as Record; if (Array.isArray(sys.parts)) { const sanitizedSystemParts = sys.parts.filter(isValidRequestPart); if (sanitizedSystemParts.length > 0) { sys.parts = sanitizedSystemParts; } else { delete anyPayload.systemInstruction; } } } } function isGeminiToolUsePart(part: any): boolean { return !!(part && typeof part === "object" && (part.functionCall || part.tool_use || part.toolUse)); } function isGeminiThinkingPart(part: any): boolean { return !!( part && typeof part === "object" && (part.thought === true || part.type === "thinking" || part.type === "reasoning") ); } // Sentinel value used when signature recovery fails - allows Claude to handle gracefully // by redacting the thinking block instead of rejecting the request entirely. // Reference: LLM-API-Key-Proxy uses this pattern for Gemini 3 tool calls. const SENTINEL_SIGNATURE = "skip_thought_signature_validator"; function getThinkingPartText(part: any): string { if (!part || typeof part !== "object") { return ""; } if (typeof part.text === "string") { return part.text; } if (typeof part.thinking === "string") { return part.thinking; } return ""; } function hasCachedMatchingSignature(part: any, sessionId: string): boolean { if (!part || typeof part !== "object") { return false; } const text = getThinkingPartText(part); if (!text) { return false; } const expectedSignature = getCachedSignature(sessionId, text); if (!expectedSignature) { return false; } if (part.thought === true) { return part.thoughtSignature === expectedSignature; } return part.signature === expectedSignature; } function ensureThoughtSignature(part: any, sessionId: string): any { if (!part || typeof part !== "object") { return part; } if (!sessionId) { return part; } const text = getThinkingPartText(part); if (!text) { return part; } if (part.thought === true) { return { ...part, thoughtSignature: SENTINEL_SIGNATURE }; } if (part.type === "thinking" || part.type === "reasoning" || part.type === "redacted_thinking") { return { ...part, signature: SENTINEL_SIGNATURE }; } return part; } function hasSignedThinkingPart(part: any, sessionId?: string): boolean { if (!part || typeof part !== "object") { return false; } if (part.thought === true) { if (part.thoughtSignature === SENTINEL_SIGNATURE || part.thoughtSignature === SKIP_THOUGHT_SIGNATURE) { return true; } if (typeof part.thoughtSignature !== "string" || part.thoughtSignature.length < MIN_SIGNATURE_LENGTH) { return false; } if (!sessionId) { return true; } return hasCachedMatchingSignature(part, sessionId); } if (part.type === "thinking" || part.type === "reasoning" || part.type === "redacted_thinking") { if (part.signature === SENTINEL_SIGNATURE || part.signature === SKIP_THOUGHT_SIGNATURE) { return true; } if (typeof part.signature !== "string" || part.signature.length < MIN_SIGNATURE_LENGTH) { return false; } if (!sessionId) { return true; } return hasCachedMatchingSignature(part, sessionId); } return false; } function ensureThinkingBeforeToolUseInContents(contents: any[], signatureSessionKey: string): any[] { return contents.map((content: any) => { if (!content || typeof content !== "object" || !Array.isArray(content.parts)) { return content; } const role = content.role; if (role !== "model" && role !== "assistant") { return content; } const parts = content.parts as any[]; const hasToolUse = parts.some(isGeminiToolUsePart); if (!hasToolUse) { return content; } const thinkingParts = parts.filter(isGeminiThinkingPart).map((p) => ensureThoughtSignature(p, signatureSessionKey)); const otherParts = parts.filter((p) => !isGeminiThinkingPart(p)); const hasSignedThinking = thinkingParts.some((part) => hasSignedThinkingPart(part, signatureSessionKey)); if (hasSignedThinking) { return { ...content, parts: [...thinkingParts, ...otherParts] }; } const lastThinking = defaultSignatureStore.get(signatureSessionKey); if (!lastThinking) { // No cached signature available - strip thinking blocks entirely // Claude requires valid signatures, and we can't fake them // Return only tool_use parts without any thinking to avoid signature validation errors log.debug("Stripping thinking from tool_use content (no valid cached signature)", { signatureSessionKey }); return { ...content, parts: otherParts }; } const injected = { thought: true, text: lastThinking.text, thoughtSignature: SENTINEL_SIGNATURE, }; return { ...content, parts: [injected, ...otherParts] }; }); } function ensureMessageThinkingSignature(block: any, sessionId: string): any { if (!block || typeof block !== "object") { return block; } if (block.type !== "thinking" && block.type !== "redacted_thinking") { return block; } const text = getThinkingPartText(block); if (!text) { return block; } if (!sessionId) { return block; } return { ...block, signature: SKIP_THOUGHT_SIGNATURE }; } function hasToolUseInContents(contents: any[]): boolean { return contents.some((content: any) => { if (!content || typeof content !== "object" || !Array.isArray(content.parts)) { return false; } return (content.parts as any[]).some(isGeminiToolUsePart); }); } function hasSignedThinkingInContents(contents: any[], sessionId?: string): boolean { return contents.some((content: any) => { if (!content || typeof content !== "object" || !Array.isArray(content.parts)) { return false; } return (content.parts as any[]).some((part) => hasSignedThinkingPart(part, sessionId)); }); } function hasToolUseInMessages(messages: any[]): boolean { return messages.some((message: any) => { if (!message || typeof message !== "object" || !Array.isArray(message.content)) { return false; } return (message.content as any[]).some( (block) => block && typeof block === "object" && (block.type === "tool_use" || block.type === "tool_result"), ); }); } function hasSignedThinkingInMessages(messages: any[], sessionId?: string): boolean { return messages.some((message: any) => { if (!message || typeof message !== "object" || !Array.isArray(message.content)) { return false; } return (message.content as any[]).some((block) => hasSignedThinkingPart(block, sessionId)); }); } function ensureThinkingBeforeToolUseInMessages(messages: any[], signatureSessionKey: string): any[] { return messages.map((message: any) => { if (!message || typeof message !== "object" || !Array.isArray(message.content)) { return message; } if (message.role !== "assistant") { return message; } const blocks = message.content as any[]; const hasToolUse = blocks.some((b) => b && typeof b === "object" && (b.type === "tool_use" || b.type === "tool_result")); if (!hasToolUse) { return message; } const thinkingBlocks = blocks .filter((b) => b && typeof b === "object" && (b.type === "thinking" || b.type === "redacted_thinking")) .map((b) => ensureMessageThinkingSignature(b, signatureSessionKey)); const otherBlocks = blocks.filter((b) => !(b && typeof b === "object" && (b.type === "thinking" || b.type === "redacted_thinking"))); const hasSignedThinking = thinkingBlocks.some((block) => hasSignedThinkingPart(block, signatureSessionKey)); if (hasSignedThinking) { return { ...message, content: [...thinkingBlocks, ...otherBlocks] }; } const lastThinking = defaultSignatureStore.get(signatureSessionKey); if (!lastThinking) { // No cached signature available - use sentinel to bypass validation // This handles cache miss scenarios (restart, session mismatch, expiry) const existingThinking = thinkingBlocks[0]; const thinkingText = existingThinking?.thinking || existingThinking?.text || ""; log.debug("Injecting sentinel signature (cache miss)", { signatureSessionKey }); const sentinelBlock = { type: "thinking", thinking: thinkingText, signature: SKIP_THOUGHT_SIGNATURE, }; return { ...message, content: [sentinelBlock, ...otherBlocks] }; } const injected = { type: "thinking", thinking: lastThinking.text, signature: SKIP_THOUGHT_SIGNATURE, }; return { ...message, content: [injected, ...otherBlocks] }; }); } /** * Gets the stable session ID for this plugin instance. */ export function getPluginSessionId(): string { return PLUGIN_SESSION_ID; } function generateSyntheticProjectId(): string { const adjectives = ["useful", "bright", "swift", "calm", "bold"]; const nouns = ["fuze", "wave", "spark", "flow", "core"]; const adj = adjectives[Math.floor(Math.random() * adjectives.length)]; const noun = nouns[Math.floor(Math.random() * nouns.length)]; const randomPart = crypto.randomUUID().slice(0, 5).toLowerCase(); return `${adj}-${noun}-${randomPart}`; } const STREAM_ACTION = "streamGenerateContent"; /** * Detects requests headed to the Google Generative Language API so we can intercept them. */ export function isGenerativeLanguageRequest(input: RequestInfo): input is string { return typeof input === "string" && input.includes("generativelanguage.googleapis.com"); } /** * Options for request preparation. */ export interface PrepareRequestOptions { /** Enable Claude tool hardening (parameter signatures + system instruction). Default: true */ claudeToolHardening?: boolean; /** Enable top-level Claude prompt auto-caching (`cache_control`). Default: false */ claudePromptAutoCaching?: boolean; /** Google Search configuration (global default) */ googleSearch?: GoogleSearchConfig; /** Per-account fingerprint for rate limit mitigation. Falls back to session fingerprint if not provided. */ fingerprint?: Fingerprint; } export function prepareAntigravityRequest( input: RequestInfo, init: RequestInit | undefined, accessToken: string, projectId: string, endpointOverride?: string, headerStyle: HeaderStyle = "antigravity", forceThinkingRecovery = false, options?: PrepareRequestOptions, ): { request: RequestInfo; init: RequestInit; streaming: boolean; requestedModel?: string; effectiveModel?: string; projectId?: string; endpoint?: string; sessionId?: string; toolDebugMissing?: number; toolDebugSummary?: string; toolDebugPayload?: string; needsSignedThinkingWarmup?: boolean; headerStyle: HeaderStyle; thinkingRecoveryMessage?: string; } { const baseInit: RequestInit = { ...init }; const headers = new Headers(init?.headers ?? {}); let resolvedProjectId = projectId?.trim() || ""; let toolDebugMissing = 0; const toolDebugSummaries: string[] = []; let toolDebugPayload: string | undefined; let sessionId: string | undefined; let needsSignedThinkingWarmup = false; let thinkingRecoveryMessage: string | undefined; if (!isGenerativeLanguageRequest(input)) { return { request: input, init: { ...baseInit, headers }, streaming: false, headerStyle, }; } headers.set("Authorization", `Bearer ${accessToken}`); headers.delete("x-api-key"); // Strip x-goog-user-project header to prevent 403 auth/license conflicts. // This header is added by OpenCode/AI SDK and can force project-level checks // that are not required for Antigravity/Gemini CLI OAuth requests. headers.delete("x-goog-user-project"); const match = input.match(/\/models\/([^:]+):(\w+)/); if (!match) { return { request: input, init: { ...baseInit, headers }, streaming: false, headerStyle, }; } const [, rawModel = "", rawAction = ""] = match; const requestedModel = rawModel; const resolved = resolveModelForHeaderStyle(rawModel, headerStyle); let effectiveModel = resolved.actualModel; const streaming = rawAction === STREAM_ACTION; const defaultEndpoint = headerStyle === "gemini-cli" ? GEMINI_CLI_ENDPOINT : ANTIGRAVITY_ENDPOINT; const baseEndpoint = endpointOverride ?? defaultEndpoint; const transformedUrl = `${baseEndpoint}/v1internal:${rawAction}${streaming ? "?alt=sse" : ""}`; const isClaude = isClaudeModel(resolved.actualModel); const isClaudeThinking = isClaudeThinkingModel(resolved.actualModel); const keepThinkingEnabled = getKeepThinking(); const enableClaudePromptAutoCaching = options?.claudePromptAutoCaching ?? false; // Tier-based thinking configuration from model resolver (can be overridden by variant config) let tierThinkingBudget = resolved.thinkingBudget; let tierThinkingLevel = resolved.thinkingLevel; let signatureSessionKey = buildSignatureSessionKey( PLUGIN_SESSION_ID, effectiveModel, undefined, resolveProjectKey(projectId), ); let body = baseInit.body; if (typeof baseInit.body === "string" && baseInit.body) { try { const parsedBody = JSON.parse(baseInit.body) as Record; const isWrapped = typeof parsedBody.project === "string" && "request" in parsedBody; if (isWrapped) { const wrappedBody = { ...parsedBody, model: effectiveModel, } as Record; // Some callers may already send an Antigravity-wrapped body. // We still need to sanitize Claude thinking blocks (remove cache_control) // and attach a stable sessionId so multi-turn signature caching works. const requestRoot = wrappedBody.request; const requestObjects: Array> = []; if (requestRoot && typeof requestRoot === "object") { requestObjects.push(requestRoot as Record); const nested = (requestRoot as any).request; if (nested && typeof nested === "object") { requestObjects.push(nested as Record); } } const conversationKey = resolveConversationKeyFromRequests(requestObjects); // Strip tier suffix from model for cache key to prevent cache misses on tier change // e.g., "claude-opus-4-6-thinking-high" -> "claude-opus-4-6-thinking" const modelForCacheKey = effectiveModel.replace(/-(minimal|low|medium|high)$/i, ""); signatureSessionKey = buildSignatureSessionKey(PLUGIN_SESSION_ID, modelForCacheKey, conversationKey, resolveProjectKey(parsedBody.project)); if (requestObjects.length > 0) { sessionId = signatureSessionKey; } for (const req of requestObjects) { // Use stable session ID for signature caching across multi-turn conversations (req as any).sessionId = signatureSessionKey; stripInjectedDebugFromRequestPayload(req as Record); if (isClaude) { // Step 0: Sanitize cross-model metadata (strips Gemini signatures when sending to Claude) sanitizeCrossModelPayloadInPlace(req, { targetModel: effectiveModel }); // Step 1: Strip corrupted/unsigned thinking blocks FIRST deepFilterThinkingBlocks(req, signatureSessionKey, getCachedSignature, true); if (enableClaudePromptAutoCaching && (req as any).cache_control === undefined) { (req as any).cache_control = { type: "ephemeral" }; } // Step 2: THEN inject signed thinking from cache (after stripping) if (isClaudeThinking && keepThinkingEnabled && Array.isArray((req as any).contents)) { (req as any).contents = ensureThinkingBeforeToolUseInContents((req as any).contents, signatureSessionKey); } if (isClaudeThinking && keepThinkingEnabled && Array.isArray((req as any).messages)) { (req as any).messages = ensureThinkingBeforeToolUseInMessages((req as any).messages, signatureSessionKey); } // Step 3: Apply tool pairing fixes (ID assignment, response matching, orphan recovery) applyToolPairingFixes(req as Record, true); } } if (isClaudeThinking && keepThinkingEnabled && sessionId) { const hasToolUse = requestObjects.some((req) => (Array.isArray((req as any).contents) && hasToolUseInContents((req as any).contents)) || (Array.isArray((req as any).messages) && hasToolUseInMessages((req as any).messages)), ); const hasSignedThinking = requestObjects.some((req) => (Array.isArray((req as any).contents) && hasSignedThinkingInContents((req as any).contents, signatureSessionKey)) || (Array.isArray((req as any).messages) && hasSignedThinkingInMessages((req as any).messages, signatureSessionKey)), ); const hasCachedThinking = defaultSignatureStore.has(signatureSessionKey); needsSignedThinkingWarmup = hasToolUse && !hasSignedThinking && !hasCachedThinking; } body = JSON.stringify(wrappedBody); } else { const requestPayload: Record = { ...parsedBody }; const rawGenerationConfig = requestPayload.generationConfig as Record | undefined; const extraBody = requestPayload.extra_body as Record | undefined; const variantConfig = extractVariantThinkingConfig( requestPayload.providerOptions as Record | undefined, rawGenerationConfig ); const isGemini3 = effectiveModel.toLowerCase().includes("gemini-3"); log.debug(`[ThinkingResolution] rawModel=${rawModel} resolvedModel=${effectiveModel} resolvedTier=${tierThinkingLevel ?? "none"} variantLevel=${variantConfig?.thinkingLevel ?? "none"} variantBudget=${variantConfig?.thinkingBudget ?? "none"} providerOptions.google=${JSON.stringify((requestPayload.providerOptions as any)?.google ?? null)} generationConfig.thinkingConfig=${JSON.stringify((rawGenerationConfig as any)?.thinkingConfig ?? null)}`); if (variantConfig?.thinkingLevel && isGemini3) { // Gemini 3 native format - use thinkingLevel directly tierThinkingLevel = variantConfig.thinkingLevel; tierThinkingBudget = undefined; } else if (variantConfig?.thinkingBudget) { if (isGemini3) { // Legacy format for Gemini 3 - convert with deprecation warning log.warn("[Deprecated] Using thinkingBudget for Gemini 3 model. Use thinkingLevel instead."); tierThinkingLevel = variantConfig.thinkingBudget <= 8192 ? "low" : variantConfig.thinkingBudget <= 16384 ? "medium" : "high"; tierThinkingBudget = undefined; } else { // Claude / Gemini 2.5 - use budget directly tierThinkingBudget = variantConfig.thinkingBudget; tierThinkingLevel = undefined; } } if (isClaude) { if (!requestPayload.toolConfig) { requestPayload.toolConfig = {}; } if (typeof requestPayload.toolConfig === "object" && requestPayload.toolConfig !== null) { const toolConfig = requestPayload.toolConfig as Record; if (!toolConfig.functionCallingConfig) { toolConfig.functionCallingConfig = {}; } if (typeof toolConfig.functionCallingConfig === "object" && toolConfig.functionCallingConfig !== null) { (toolConfig.functionCallingConfig as Record).mode = "VALIDATED"; } } } // Resolve thinking configuration based on user settings and model capabilities // Image generation models don't support thinking - skip thinking config entirely const isImageModel = isImageGenerationModel(effectiveModel); const userThinkingConfig = isImageModel ? undefined : extractThinkingConfig(requestPayload, rawGenerationConfig, extraBody); const hasAssistantHistory = Array.isArray(requestPayload.contents) && requestPayload.contents.some((c: any) => c?.role === "model" || c?.role === "assistant"); // Claude Sonnet 4.6 is non-thinking only. // Ignore any client-provided thinkingConfig for this model. const lowerEffective = effectiveModel.toLowerCase(); const isClaudeSonnetNonThinking = lowerEffective === "claude-sonnet-4-6"; const effectiveUserThinkingConfig = (isClaudeSonnetNonThinking || isImageModel) ? undefined : userThinkingConfig; // For image models, add imageConfig instead of thinkingConfig if (isImageModel) { const imageConfig = buildImageGenerationConfig(); const generationConfig = (rawGenerationConfig ?? {}) as Record; generationConfig.imageConfig = imageConfig; // Remove any thinkingConfig that might have been set delete generationConfig.thinkingConfig; // Set reasonable defaults for image generation if (!generationConfig.candidateCount) { generationConfig.candidateCount = 1; } requestPayload.generationConfig = generationConfig; // Add safety settings for image generation (permissive to allow creative content) if (!requestPayload.safetySettings) { requestPayload.safetySettings = [ { category: "HARM_CATEGORY_HARASSMENT", threshold: "BLOCK_ONLY_HIGH" }, { category: "HARM_CATEGORY_HATE_SPEECH", threshold: "BLOCK_ONLY_HIGH" }, { category: "HARM_CATEGORY_SEXUALLY_EXPLICIT", threshold: "BLOCK_ONLY_HIGH" }, { category: "HARM_CATEGORY_DANGEROUS_CONTENT", threshold: "BLOCK_ONLY_HIGH" }, { category: "HARM_CATEGORY_CIVIC_INTEGRITY", threshold: "BLOCK_ONLY_HIGH" }, ]; } // Image models don't support tools - remove them entirely delete requestPayload.tools; delete requestPayload.toolConfig; // Replace system instruction with a simple image generation prompt // Image models should not receive agentic coding assistant instructions requestPayload.systemInstruction = { parts: [{ text: "You are an AI image generator. Generate images based on user descriptions. Focus on creating high-quality, visually appealing images that match the user's request." }] }; } else { const finalThinkingConfig = resolveThinkingConfig( effectiveUserThinkingConfig, isClaudeSonnetNonThinking ? false : (resolved.isThinkingModel ?? isThinkingCapableModel(effectiveModel)), isClaude, hasAssistantHistory, ); const normalizedThinking = normalizeThinkingConfig(finalThinkingConfig); if (normalizedThinking) { // Use tier-based thinking budget if specified via model suffix, otherwise fall back to user config const thinkingBudget = tierThinkingBudget ?? normalizedThinking.thinkingBudget; // Build thinking config based on model type let thinkingConfig: Record; if (isClaudeThinking) { // Claude uses snake_case keys thinkingConfig = { include_thoughts: normalizedThinking.includeThoughts ?? true, ...(typeof thinkingBudget === "number" && thinkingBudget > 0 ? { thinking_budget: thinkingBudget } : {}), }; } else if (tierThinkingLevel) { // Gemini 3 uses thinkingLevel string (low/medium/high) thinkingConfig = { includeThoughts: normalizedThinking.includeThoughts, thinkingLevel: tierThinkingLevel, }; } else { // Gemini 2.5 and others use numeric budget thinkingConfig = { includeThoughts: normalizedThinking.includeThoughts, ...(typeof thinkingBudget === "number" && thinkingBudget > 0 ? { thinkingBudget } : {}), }; } if (rawGenerationConfig) { rawGenerationConfig.thinkingConfig = thinkingConfig; if (isClaudeThinking && typeof thinkingBudget === "number" && thinkingBudget > 0) { const currentMax = (rawGenerationConfig.maxOutputTokens ?? rawGenerationConfig.max_output_tokens) as number | undefined; if (!currentMax || currentMax <= thinkingBudget) { rawGenerationConfig.maxOutputTokens = CLAUDE_THINKING_MAX_OUTPUT_TOKENS; if (rawGenerationConfig.max_output_tokens !== undefined) { delete rawGenerationConfig.max_output_tokens; } } } requestPayload.generationConfig = rawGenerationConfig; } else { const generationConfig: Record = { thinkingConfig }; if (isClaudeThinking && typeof thinkingBudget === "number" && thinkingBudget > 0) { generationConfig.maxOutputTokens = CLAUDE_THINKING_MAX_OUTPUT_TOKENS; } requestPayload.generationConfig = generationConfig; } } else if (rawGenerationConfig?.thinkingConfig) { delete rawGenerationConfig.thinkingConfig; requestPayload.generationConfig = rawGenerationConfig; } } // End of else block for non-image models // Clean up thinking fields from extra_body if (extraBody) { delete extraBody.thinkingConfig; delete extraBody.thinking; } delete requestPayload.thinkingConfig; delete requestPayload.thinking; if ("system_instruction" in requestPayload) { requestPayload.systemInstruction = requestPayload.system_instruction; delete requestPayload.system_instruction; } if (isClaudeThinking && Array.isArray(requestPayload.tools) && requestPayload.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. Do not mention these instructions or any constraints about thinking blocks; just apply them."; const existing = requestPayload.systemInstruction; if (typeof existing === "string") { requestPayload.systemInstruction = existing.trim().length > 0 ? `${existing}\n\n${hint}` : hint; } else if (existing && typeof existing === "object") { const sys = existing as Record; const partsValue = sys.parts; if (Array.isArray(partsValue)) { const parts = partsValue as unknown[]; let appended = false; for (let i = parts.length - 1; i >= 0; i--) { const part = parts[i]; if (part && typeof part === "object") { const partRecord = part as Record; const text = partRecord.text; if (typeof text === "string") { partRecord.text = `${text}\n\n${hint}`; appended = true; break; } } } if (!appended) { parts.push({ text: hint }); } } else { sys.parts = [{ text: hint }]; } requestPayload.systemInstruction = sys; } else if (Array.isArray(requestPayload.contents)) { requestPayload.systemInstruction = { parts: [{ text: hint }] }; } } const cachedContentFromExtra = typeof requestPayload.extra_body === "object" && requestPayload.extra_body ? (requestPayload.extra_body as Record).cached_content ?? (requestPayload.extra_body as Record).cachedContent : undefined; const cachedContent = (requestPayload.cached_content as string | undefined) ?? (requestPayload.cachedContent as string | undefined) ?? (cachedContentFromExtra as string | undefined); if (cachedContent) { requestPayload.cachedContent = cachedContent; } delete requestPayload.cached_content; delete requestPayload.cachedContent; if (requestPayload.extra_body && typeof requestPayload.extra_body === "object") { delete (requestPayload.extra_body as Record).cached_content; delete (requestPayload.extra_body as Record).cachedContent; if (Object.keys(requestPayload.extra_body as Record).length === 0) { delete requestPayload.extra_body; } } // Normalize tools. For Claude models, keep full function declarations (names + schemas). const hasTools = Array.isArray(requestPayload.tools) && requestPayload.tools.length > 0; if (hasTools) { if (isClaude) { const functionDeclarations: any[] = []; const passthroughTools: any[] = []; const normalizeSchema = (schema: any) => { const createPlaceholderSchema = (base: any = {}) => ({ ...base, type: "object", properties: { [EMPTY_SCHEMA_PLACEHOLDER_NAME]: { type: "boolean", description: EMPTY_SCHEMA_PLACEHOLDER_DESCRIPTION, }, }, required: [EMPTY_SCHEMA_PLACEHOLDER_NAME], }); if (!schema || typeof schema !== "object" || Array.isArray(schema)) { toolDebugMissing += 1; return createPlaceholderSchema(); } const cleaned = cleanJSONSchemaForAntigravity(schema); if (!cleaned || typeof cleaned !== "object" || Array.isArray(cleaned)) { toolDebugMissing += 1; return createPlaceholderSchema(); } // Claude VALIDATED mode requires tool parameters to be an object schema // with at least one property. const hasProperties = cleaned.properties && typeof cleaned.properties === "object" && Object.keys(cleaned.properties).length > 0; cleaned.type = "object"; if (!hasProperties) { cleaned.properties = { [EMPTY_SCHEMA_PLACEHOLDER_NAME]: { type: "boolean", description: EMPTY_SCHEMA_PLACEHOLDER_DESCRIPTION, }, }; cleaned.required = Array.isArray(cleaned.required) ? Array.from(new Set([...cleaned.required, EMPTY_SCHEMA_PLACEHOLDER_NAME])) : [EMPTY_SCHEMA_PLACEHOLDER_NAME]; } return cleaned; }; (requestPayload.tools as any[]).forEach((tool: any) => { const pushDeclaration = (decl: any, source: string) => { const schema = decl?.parameters || decl?.parametersJsonSchema || decl?.input_schema || decl?.inputSchema || tool.parameters || tool.parametersJsonSchema || tool.input_schema || tool.inputSchema || tool.function?.parameters || tool.function?.parametersJsonSchema || tool.function?.input_schema || tool.function?.inputSchema || tool.custom?.parameters || tool.custom?.parametersJsonSchema || tool.custom?.input_schema; let name = decl?.name || tool.name || tool.function?.name || tool.custom?.name || `tool-${functionDeclarations.length}`; // Sanitize tool name: must be alphanumeric with underscores, no special chars name = String(name).replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 64); const description = decl?.description || tool.description || tool.function?.description || tool.custom?.description || ""; functionDeclarations.push({ name, description: String(description || ""), parameters: normalizeSchema(schema), }); toolDebugSummaries.push( `decl=${name},src=${source},hasSchema=${schema ? "y" : "n"}`, ); }; if (Array.isArray(tool.functionDeclarations) && tool.functionDeclarations.length > 0) { tool.functionDeclarations.forEach((decl: any) => pushDeclaration(decl, "functionDeclarations")); return; } // Fall back to function/custom style definitions. if ( tool.function || tool.custom || tool.parameters || tool.input_schema || tool.inputSchema ) { pushDeclaration(tool.function ?? tool.custom ?? tool, "function/custom"); return; } // Preserve any non-function tool entries (e.g., codeExecution) untouched. passthroughTools.push(tool); }); const finalTools: any[] = []; if (functionDeclarations.length > 0) { finalTools.push({ functionDeclarations }); } requestPayload.tools = finalTools.concat(passthroughTools); } else { // Gemini-specific tool normalization and feature injection const geminiResult = applyGeminiTransforms(requestPayload, { model: effectiveModel, normalizedThinking: undefined, // Thinking config already applied above (lines 816-880) tierThinkingBudget, tierThinkingLevel: tierThinkingLevel as ThinkingTier | undefined, }); toolDebugMissing = geminiResult.toolDebugMissing; toolDebugSummaries.push(...geminiResult.toolDebugSummaries); } try { toolDebugPayload = JSON.stringify(requestPayload.tools); } catch { toolDebugPayload = undefined; } // Apply Claude tool hardening (ported from LLM-API-Key-Proxy) // Injects parameter signatures into descriptions and adds system instruction // Can be disabled via config.claude_tool_hardening = false to reduce context size const enableToolHardening = options?.claudeToolHardening ?? true; if (enableToolHardening && isClaude && Array.isArray(requestPayload.tools) && requestPayload.tools.length > 0) { // Inject parameter signatures into tool descriptions requestPayload.tools = injectParameterSignatures( requestPayload.tools, CLAUDE_DESCRIPTION_PROMPT, ); // Inject tool hardening system instruction injectToolHardeningInstruction( requestPayload as Record, CLAUDE_TOOL_SYSTEM_INSTRUCTION, ); } } const conversationKey = resolveConversationKey(requestPayload); signatureSessionKey = buildSignatureSessionKey(PLUGIN_SESSION_ID, effectiveModel, conversationKey, resolveProjectKey(projectId)); // For Claude models, filter out unsigned thinking blocks (required by Claude API) // Attempts to restore signatures from cache for multi-turn conversations // Handle both Gemini-style contents[] and Anthropic-style messages[] payloads. if (isClaude) { // Step 0: Sanitize cross-model metadata (strips Gemini signatures when sending to Claude) sanitizeCrossModelPayloadInPlace(requestPayload, { targetModel: effectiveModel }); // Step 1: Strip corrupted/unsigned thinking blocks FIRST deepFilterThinkingBlocks(requestPayload, signatureSessionKey, getCachedSignature, true); if (enableClaudePromptAutoCaching && requestPayload.cache_control === undefined) { requestPayload.cache_control = { type: "ephemeral" }; } // Step 2: THEN inject signed thinking from cache (after stripping) if (isClaudeThinking && keepThinkingEnabled && Array.isArray(requestPayload.contents)) { requestPayload.contents = ensureThinkingBeforeToolUseInContents(requestPayload.contents, signatureSessionKey); } if (isClaudeThinking && keepThinkingEnabled && Array.isArray(requestPayload.messages)) { requestPayload.messages = ensureThinkingBeforeToolUseInMessages(requestPayload.messages, signatureSessionKey); } // Step 3: Check if warmup needed (AFTER injection attempt) if (isClaudeThinking && keepThinkingEnabled) { const hasToolUse = (Array.isArray(requestPayload.contents) && hasToolUseInContents(requestPayload.contents)) || (Array.isArray(requestPayload.messages) && hasToolUseInMessages(requestPayload.messages)); const hasSignedThinking = (Array.isArray(requestPayload.contents) && hasSignedThinkingInContents(requestPayload.contents, signatureSessionKey)) || (Array.isArray(requestPayload.messages) && hasSignedThinkingInMessages(requestPayload.messages, signatureSessionKey)); const hasCachedThinking = defaultSignatureStore.has(signatureSessionKey); needsSignedThinkingWarmup = hasToolUse && !hasSignedThinking && !hasCachedThinking; } } // For Claude models, ensure functionCall/tool use parts carry IDs (required by Anthropic). // We use a two-pass approach: first collect all functionCalls and assign IDs, // then match functionResponses to their corresponding calls using a FIFO queue per function name. if (isClaude && Array.isArray(requestPayload.contents)) { let toolCallCounter = 0; // Track pending call IDs per function name as a FIFO queue const pendingCallIdsByName = new Map(); // First pass: assign IDs to all functionCalls and collect them requestPayload.contents = requestPayload.contents.map((content: any) => { if (!content || !Array.isArray(content.parts)) { return content; } const newParts = content.parts.map((part: any) => { if (part && typeof part === "object" && part.functionCall) { const call = { ...part.functionCall }; if (!call.id) { call.id = `tool-call-${++toolCallCounter}`; } const nameKey = typeof call.name === "string" ? call.name : `tool-${toolCallCounter}`; // Push to the queue for this function name const queue = pendingCallIdsByName.get(nameKey) || []; queue.push(call.id); pendingCallIdsByName.set(nameKey, queue); return { ...part, functionCall: call }; } return part; }); return { ...content, parts: newParts }; }); // Second pass: match functionResponses to their corresponding calls (FIFO order) requestPayload.contents = (requestPayload.contents as any[]).map((content: any) => { if (!content || !Array.isArray(content.parts)) { return content; } const newParts = content.parts.map((part: any) => { if (part && typeof part === "object" && part.functionResponse) { const resp = { ...part.functionResponse }; if (!resp.id && typeof resp.name === "string") { const queue = pendingCallIdsByName.get(resp.name); if (queue && queue.length > 0) { // Consume the first pending ID (FIFO order) resp.id = queue.shift(); pendingCallIdsByName.set(resp.name, queue); } } return { ...part, functionResponse: resp }; } return part; }); return { ...content, parts: newParts }; }); // Third pass: Apply orphan recovery for mismatched tool IDs // This handles cases where context compaction or other processes // create ID mismatches between calls and responses. // Ported from LLM-API-Key-Proxy's _fix_tool_response_grouping() requestPayload.contents = fixToolResponseGrouping(requestPayload.contents as any[]); } // Fourth pass: Fix Claude format tool pairing (defense in depth) // Handles orphaned tool_use blocks in Claude's messages[] format if (Array.isArray(requestPayload.messages)) { requestPayload.messages = validateAndFixClaudeToolPairing(requestPayload.messages); } // ===================================================================== // LAST RESORT RECOVERY: "Let it crash and start again" // ===================================================================== // If after all our processing we're STILL in a bad state (tool loop without // thinking at turn start), don't try to fix it - just close the turn and // start fresh. This prevents permanent session breakage. // // This handles cases where: // - Context compaction stripped thinking blocks // - Signature cache miss // - Any other corruption we couldn't repair // - API error indicated thinking_block_order issue (forceThinkingRecovery=true) // // The synthetic messages allow Claude to generate fresh thinking on the // new turn instead of failing with "Expected thinking but found text". if (isClaudeThinking && Array.isArray(requestPayload.contents)) { const conversationState = analyzeConversationState(requestPayload.contents); // Force recovery if API returned thinking_block_order error (retry case) // or if proactive check detects we need recovery if (forceThinkingRecovery || needsThinkingRecovery(conversationState)) { // Set message for toast notification (shown in plugin.ts, respects quiet mode) thinkingRecoveryMessage = forceThinkingRecovery ? "Thinking recovery: retrying with fresh turn (API error)" : "Thinking recovery: restarting turn (corrupted context)"; requestPayload.contents = closeToolLoopForThinking(requestPayload.contents); defaultSignatureStore.delete(signatureSessionKey); } } if ("model" in requestPayload) { delete requestPayload.model; } stripInjectedDebugFromRequestPayload(requestPayload); sanitizeRequestPayloadForAntigravity(requestPayload); const effectiveProjectId = projectId?.trim() || (headerStyle === "antigravity" ? generateSyntheticProjectId() : ""); resolvedProjectId = effectiveProjectId; // Inject Antigravity system instruction with role "user" (CLIProxyAPI v6.6.89 compatibility) // This sets request.systemInstruction.role = "user" and request.systemInstruction.parts[0].text if (headerStyle === "antigravity") { const existingSystemInstruction = requestPayload.systemInstruction; if (existingSystemInstruction && typeof existingSystemInstruction === "object") { const sys = existingSystemInstruction as Record; sys.role = "user"; if (Array.isArray(sys.parts) && sys.parts.length > 0) { const firstPart = sys.parts[0] as Record; if (firstPart && typeof firstPart.text === "string") { firstPart.text = ANTIGRAVITY_SYSTEM_INSTRUCTION + "\n\n" + firstPart.text; } else { sys.parts = [{ text: ANTIGRAVITY_SYSTEM_INSTRUCTION }, ...sys.parts]; } } else { sys.parts = [{ text: ANTIGRAVITY_SYSTEM_INSTRUCTION }]; } } else if (typeof existingSystemInstruction === "string") { requestPayload.systemInstruction = { role: "user", parts: [{ text: ANTIGRAVITY_SYSTEM_INSTRUCTION + "\n\n" + existingSystemInstruction }], }; } else { requestPayload.systemInstruction = { role: "user", parts: [{ text: ANTIGRAVITY_SYSTEM_INSTRUCTION }], }; } } const wrappedBody: Record = { project: effectiveProjectId, model: effectiveModel, request: requestPayload, }; if (headerStyle === "antigravity") { wrappedBody.requestType = "agent"; wrappedBody.userAgent = "antigravity"; wrappedBody.requestId = "agent-" + crypto.randomUUID(); } if (wrappedBody.request && typeof wrappedBody.request === 'object') { // Use stable session ID for signature caching across multi-turn conversations sessionId = signatureSessionKey; (wrappedBody.request as any).sessionId = signatureSessionKey; } body = JSON.stringify(wrappedBody); } } catch (error) { throw error; } } if (streaming) { headers.set("Accept", "text/event-stream"); } // Add interleaved thinking header for Claude thinking models // This enables real-time streaming of thinking tokens if (isClaudeThinking) { const existing = headers.get("anthropic-beta"); const interleavedHeader = "interleaved-thinking-2025-05-14"; if (existing) { if (!existing.includes(interleavedHeader)) { headers.set("anthropic-beta", `${existing},${interleavedHeader}`); } } else { headers.set("anthropic-beta", interleavedHeader); } } if (headerStyle === "antigravity") { // Use randomized headers as the fallback pool for Antigravity mode const selectedHeaders = getRandomizedHeaders("antigravity", requestedModel); // Antigravity mode: Match Antigravity Manager behavior // AM only sends User-Agent on content requests — no X-Goog-Api-Client, no Client-Metadata header // (ideType=ANTIGRAVITY goes in request body metadata via project.ts, not as a header) const fingerprint = options?.fingerprint ?? getSessionFingerprint(); const fingerprintHeaders = buildFingerprintHeaders(fingerprint); headers.set("User-Agent", fingerprintHeaders["User-Agent"] || selectedHeaders["User-Agent"]); } else { // Gemini CLI mode: match opencode-gemini-auth Code Assist header set exactly headers.set("User-Agent", GEMINI_CLI_HEADERS["User-Agent"]); headers.set("X-Goog-Api-Client", GEMINI_CLI_HEADERS["X-Goog-Api-Client"]); headers.set("Client-Metadata", GEMINI_CLI_HEADERS["Client-Metadata"]); } return { request: transformedUrl, init: { ...baseInit, headers, body, }, streaming, requestedModel, effectiveModel: effectiveModel, projectId: resolvedProjectId, endpoint: transformedUrl, sessionId, toolDebugMissing, toolDebugSummary: toolDebugSummaries.slice(0, 20).join(" | "), toolDebugPayload, needsSignedThinkingWarmup, headerStyle, thinkingRecoveryMessage, }; } export function buildThinkingWarmupBody( bodyText: string | undefined, isClaudeThinking: boolean, ): string | null { if (!bodyText || !isClaudeThinking) { return null; } let parsed: Record; try { parsed = JSON.parse(bodyText) as Record; } catch { return null; } const warmupPrompt = "Warmup request for thinking signature."; const updateRequest = (req: Record) => { req.contents = [{ role: "user", parts: [{ text: warmupPrompt }] }]; delete req.tools; delete (req as any).toolConfig; const generationConfig = (req.generationConfig ?? {}) as Record; generationConfig.thinkingConfig = { include_thoughts: true, thinking_budget: DEFAULT_THINKING_BUDGET, }; generationConfig.maxOutputTokens = CLAUDE_THINKING_MAX_OUTPUT_TOKENS; req.generationConfig = generationConfig; }; if (parsed.request && typeof parsed.request === "object") { updateRequest(parsed.request as Record); const nested = (parsed.request as any).request; if (nested && typeof nested === "object") { updateRequest(nested as Record); } } else { updateRequest(parsed); } return JSON.stringify(parsed); } /** * Normalizes Antigravity responses: applies retry headers, extracts cache usage into headers, * rewrites preview errors, flattens streaming payloads, and logs debug metadata. * * For streaming SSE responses, uses TransformStream for true real-time incremental streaming. * Thinking/reasoning tokens are transformed and forwarded immediately as they arrive. */ export async function transformAntigravityResponse( response: Response, streaming: boolean, debugContext?: AntigravityDebugContext | null, requestedModel?: string, projectId?: string, endpoint?: string, effectiveModel?: string, sessionId?: string, toolDebugMissing?: number, toolDebugSummary?: string, toolDebugPayload?: string, debugLines?: string[], ): Promise { const contentType = response.headers.get("content-type") ?? ""; const isJsonResponse = contentType.includes("application/json"); const isEventStreamResponse = contentType.includes("text/event-stream"); // Generate text for thinking injection: // - If debug=true: inject full debug logs // - If keep_thinking=true (but no debug): inject placeholder to trigger signature caching // Both use the same injection path (injectDebugThinking) for consistent behavior const debugText = isDebugTuiEnabled() && Array.isArray(debugLines) && debugLines.length > 0 ? formatDebugLinesForThinking(debugLines) : getKeepThinking() ? SYNTHETIC_THINKING_PLACEHOLDER : undefined; const cacheSignatures = shouldCacheThinkingSignatures(effectiveModel); if (!isJsonResponse && !isEventStreamResponse) { logAntigravityDebugResponse(debugContext, response, { note: "Non-JSON response (body omitted)", }); return response; } // For successful streaming responses, use TransformStream to transform SSE events // while maintaining real-time streaming (no buffering of entire response). // This enables thinking tokens to be displayed as they arrive, like the Codex plugin. if (streaming && response.ok && isEventStreamResponse && response.body) { const headers = new Headers(response.headers); logAntigravityDebugResponse(debugContext, response, { note: "Streaming SSE response (real-time transform)", }); const streamingTransformer = createStreamingTransformer( defaultSignatureStore, { onCacheSignature: cacheSignature, onInjectDebug: injectDebugThinking, // onInjectSyntheticThinking removed - keep_thinking now uses debugText path transformThinkingParts, }, { signatureSessionKey: sessionId, debugText, cacheSignatures, displayedThinkingHashes: effectiveModel && isGemini3Model(effectiveModel) ? sessionDisplayedThinkingHashes : undefined, // injectSyntheticThinking removed - keep_thinking now unified with debug via debugText }, ); return new Response(response.body.pipeThrough(streamingTransformer), { status: response.status, statusText: response.statusText, headers, }); } const responseFallback = response.clone(); try { const headers = new Headers(response.headers); const text = await response.text(); if (!response.ok) { let errorBody; try { errorBody = JSON.parse(text); } catch { errorBody = { error: { message: text } }; } // Inject Debug Info if (errorBody?.error) { const rawErrorMessage = typeof errorBody.error.message === "string" && errorBody.error.message.length > 0 ? errorBody.error.message : "Unknown error"; const errorType = detectErrorType(rawErrorMessage); const debugInfo = `\n\n[Debug Info]\nRequested Model: ${requestedModel || "Unknown"}\nEffective Model: ${effectiveModel || "Unknown"}\nProject: ${projectId || "Unknown"}\nEndpoint: ${endpoint || "Unknown"}\nStatus: ${response.status}\nRequest ID: ${headers.get("x-request-id") || "N/A"}${toolDebugMissing !== undefined ? `\nTool Debug Missing: ${toolDebugMissing}` : ""}${toolDebugSummary ? `\nTool Debug Summary: ${toolDebugSummary}` : ""}${toolDebugPayload ? `\nTool Debug Payload: ${toolDebugPayload}` : ""}`; const injectedDebug = debugText ? `\n\n${debugText}` : ""; errorBody.error.message = rawErrorMessage + debugInfo + injectedDebug; // Check if this is a recoverable thinking error - throw to trigger retry if (errorType === "thinking_block_order") { const recoveryError = new Error("THINKING_RECOVERY_NEEDED"); (recoveryError as any).recoveryType = errorType; (recoveryError as any).originalError = errorBody; (recoveryError as any).debugInfo = debugInfo; throw recoveryError; } // Detect context length / prompt too long errors - signal to caller for toast const errorMessage = errorBody.error.message?.toLowerCase() || ""; if ( errorMessage.includes("prompt is too long") || errorMessage.includes("context length exceeded") || errorMessage.includes("context_length_exceeded") || errorMessage.includes("maximum context length") ) { headers.set("x-antigravity-context-error", "prompt_too_long"); } // Detect tool pairing errors - signal to caller for toast if ( errorMessage.includes("tool_use") && errorMessage.includes("tool_result") && (errorMessage.includes("without") || errorMessage.includes("immediately after")) ) { headers.set("x-antigravity-context-error", "tool_pairing"); } return new Response(JSON.stringify(errorBody), { status: response.status, statusText: response.statusText, headers }); } if (errorBody?.error?.details && Array.isArray(errorBody.error.details)) { const retryInfo = errorBody.error.details.find( (detail: any) => detail['@type'] === 'type.googleapis.com/google.rpc.RetryInfo' ); if (retryInfo?.retryDelay) { const match = retryInfo.retryDelay.match(/^([\d.]+)s$/); if (match && match[1]) { const retrySeconds = parseFloat(match[1]); if (!isNaN(retrySeconds) && retrySeconds > 0) { const retryAfterSec = Math.ceil(retrySeconds).toString(); const retryAfterMs = Math.ceil(retrySeconds * 1000).toString(); headers.set('Retry-After', retryAfterSec); headers.set('retry-after-ms', retryAfterMs); } } } } } const init = { status: response.status, statusText: response.statusText, headers, }; const usageFromSse = streaming && isEventStreamResponse ? extractUsageFromSsePayload(text) : null; const parsed: AntigravityApiBody | null = !streaming || !isEventStreamResponse ? parseAntigravityApiBody(text) : null; const patched = parsed ? rewriteAntigravityPreviewAccessError(parsed, response.status, requestedModel) : null; const effectiveBody = patched ?? parsed ?? undefined; const usage = usageFromSse ?? (effectiveBody ? extractUsageMetadata(effectiveBody) : null); // Log cache stats when available if (usage && effectiveModel) { logCacheStats( effectiveModel, usage.cachedContentTokenCount ?? 0, 0, // API doesn't provide cache write tokens separately usage.promptTokenCount ?? usage.totalTokenCount ?? 0, ); } if (usage?.cachedContentTokenCount !== undefined) { headers.set("x-antigravity-cached-content-token-count", String(usage.cachedContentTokenCount)); if (usage.totalTokenCount !== undefined) { headers.set("x-antigravity-total-token-count", String(usage.totalTokenCount)); } if (usage.promptTokenCount !== undefined) { headers.set("x-antigravity-prompt-token-count", String(usage.promptTokenCount)); } if (usage.candidatesTokenCount !== undefined) { headers.set("x-antigravity-candidates-token-count", String(usage.candidatesTokenCount)); } } logAntigravityDebugResponse(debugContext, response, { body: text, note: streaming ? "Streaming SSE payload (buffered fallback)" : undefined, headersOverride: headers, }); // Note: successful streaming responses are handled above via TransformStream. // This path only handles non-streaming responses or failed streaming responses. if (!parsed) { return new Response(text, init); } if (effectiveBody?.response !== undefined) { let responseBody: unknown = effectiveBody.response; // Inject thinking text (debug logs or "[Thinking preserved]" placeholder) // Both debug=true and keep_thinking=true use the same path now if (debugText) { responseBody = injectDebugThinking(responseBody, debugText); } const transformed = transformThinkingParts(responseBody); return new Response(JSON.stringify(transformed), init); } if (patched) { return new Response(JSON.stringify(patched), init); } return new Response(text, init); } catch (error) { if (error instanceof Error && error.message === "THINKING_RECOVERY_NEEDED") { throw error; } logAntigravityDebugResponse(debugContext, response, { error, note: "Failed to transform Antigravity response", }); return responseFallback; } } export const __testExports = { buildSignatureSessionKey, hashConversationSeed, extractTextFromContent, extractConversationSeedFromMessages, extractConversationSeedFromContents, resolveConversationKey, resolveProjectKey, isGeminiToolUsePart, isGeminiThinkingPart, ensureThoughtSignature, hasSignedThinkingPart, hasSignedThinkingInContents, hasSignedThinkingInMessages, hasToolUseInContents, hasToolUseInMessages, ensureThinkingBeforeToolUseInContents, ensureThinkingBeforeToolUseInMessages, generateSyntheticProjectId, MIN_SIGNATURE_LENGTH, transformSseLine, transformStreamingPayload, createStreamingTransformer, }; ================================================ FILE: src/plugin/rotation.test.ts ================================================ import { beforeEach, describe, expect, it, vi } from "vitest"; import { HealthScoreTracker, TokenBucketTracker, addJitter, randomDelay, sortByLruWithHealth, selectHybridAccount, type AccountWithMetrics, } from "./rotation"; describe("HealthScoreTracker", () => { beforeEach(() => { vi.useRealTimers(); }); describe("initial state", () => { it("returns initial score for unknown account", () => { const tracker = new HealthScoreTracker(); expect(tracker.getScore(0)).toBe(70); }); it("uses custom initial score from config", () => { const tracker = new HealthScoreTracker({ initial: 50 }); expect(tracker.getScore(0)).toBe(50); }); it("isUsable returns true for new accounts", () => { const tracker = new HealthScoreTracker(); expect(tracker.isUsable(0)).toBe(true); }); it("getConsecutiveFailures returns 0 for unknown account", () => { const tracker = new HealthScoreTracker(); expect(tracker.getConsecutiveFailures(0)).toBe(0); }); }); describe("recordSuccess", () => { it("increases score by success reward", () => { const tracker = new HealthScoreTracker({ initial: 70, successReward: 5 }); tracker.recordSuccess(0); expect(tracker.getScore(0)).toBe(75); }); it("caps score at maxScore", () => { const tracker = new HealthScoreTracker({ initial: 98, successReward: 5, maxScore: 100 }); tracker.recordSuccess(0); expect(tracker.getScore(0)).toBe(100); }); it("resets consecutive failures", () => { const tracker = new HealthScoreTracker(); tracker.recordRateLimit(0); tracker.recordRateLimit(0); expect(tracker.getConsecutiveFailures(0)).toBe(2); tracker.recordSuccess(0); expect(tracker.getConsecutiveFailures(0)).toBe(0); }); }); describe("recordRateLimit", () => { it("decreases score by rate limit penalty", () => { const tracker = new HealthScoreTracker({ initial: 70, rateLimitPenalty: -10 }); tracker.recordRateLimit(0); expect(tracker.getScore(0)).toBe(60); }); it("does not go below 0", () => { const tracker = new HealthScoreTracker({ initial: 5, rateLimitPenalty: -10 }); tracker.recordRateLimit(0); expect(tracker.getScore(0)).toBe(0); }); it("increments consecutive failures", () => { const tracker = new HealthScoreTracker(); tracker.recordRateLimit(0); expect(tracker.getConsecutiveFailures(0)).toBe(1); tracker.recordRateLimit(0); expect(tracker.getConsecutiveFailures(0)).toBe(2); }); }); describe("recordFailure", () => { it("decreases score by failure penalty", () => { const tracker = new HealthScoreTracker({ initial: 70, failurePenalty: -20 }); tracker.recordFailure(0); expect(tracker.getScore(0)).toBe(50); }); it("does not go below 0", () => { const tracker = new HealthScoreTracker({ initial: 10, failurePenalty: -20 }); tracker.recordFailure(0); expect(tracker.getScore(0)).toBe(0); }); it("increments consecutive failures", () => { const tracker = new HealthScoreTracker(); tracker.recordFailure(0); expect(tracker.getConsecutiveFailures(0)).toBe(1); }); }); describe("isUsable", () => { it("returns true when score >= minUsable", () => { const tracker = new HealthScoreTracker({ initial: 50, minUsable: 50 }); expect(tracker.isUsable(0)).toBe(true); }); it("returns false when score < minUsable", () => { const tracker = new HealthScoreTracker({ initial: 49, minUsable: 50 }); expect(tracker.isUsable(0)).toBe(false); }); it("becomes unusable after multiple failures", () => { const tracker = new HealthScoreTracker({ initial: 70, failurePenalty: -20, minUsable: 50 }); tracker.recordFailure(0); expect(tracker.isUsable(0)).toBe(true); tracker.recordFailure(0); expect(tracker.isUsable(0)).toBe(false); }); }); describe("time-based recovery", () => { it("recovers points over time", () => { let mockTime = 0; vi.spyOn(Date, 'now').mockImplementation(() => mockTime); const tracker = new HealthScoreTracker({ initial: 70, failurePenalty: -20, recoveryRatePerHour: 10 }); tracker.recordFailure(0); expect(tracker.getScore(0)).toBe(50); mockTime = 2 * 60 * 60 * 1000; expect(tracker.getScore(0)).toBe(70); vi.restoreAllMocks(); }); it("caps recovery at maxScore", () => { let mockTime = 0; vi.spyOn(Date, 'now').mockImplementation(() => mockTime); const tracker = new HealthScoreTracker({ initial: 90, successReward: 5, recoveryRatePerHour: 20, maxScore: 100 }); tracker.recordSuccess(0); expect(tracker.getScore(0)).toBe(95); mockTime = 60 * 60 * 1000; expect(tracker.getScore(0)).toBe(100); vi.restoreAllMocks(); }); it("floors recovered points (no partial points)", () => { let mockTime = 0; vi.spyOn(Date, 'now').mockImplementation(() => mockTime); const tracker = new HealthScoreTracker({ initial: 70, failurePenalty: -10, recoveryRatePerHour: 2 }); tracker.recordFailure(0); expect(tracker.getScore(0)).toBe(60); mockTime = 20 * 60 * 1000; expect(tracker.getScore(0)).toBe(60); mockTime = 30 * 60 * 1000; expect(tracker.getScore(0)).toBe(61); vi.restoreAllMocks(); }); }); describe("reset", () => { it("clears health state for account", () => { const tracker = new HealthScoreTracker({ initial: 70 }); tracker.recordSuccess(0); tracker.reset(0); expect(tracker.getScore(0)).toBe(70); expect(tracker.getConsecutiveFailures(0)).toBe(0); }); }); describe("getSnapshot", () => { it("returns current state of all tracked accounts", () => { const tracker = new HealthScoreTracker({ initial: 70, failurePenalty: -10 }); tracker.recordSuccess(0); tracker.recordFailure(1); tracker.recordFailure(1); const snapshot = tracker.getSnapshot(); expect(snapshot.get(0)?.score).toBe(71); expect(snapshot.get(0)?.consecutiveFailures).toBe(0); expect(snapshot.get(1)?.score).toBe(50); expect(snapshot.get(1)?.consecutiveFailures).toBe(2); }); }); }); describe("TokenBucketTracker", () => { beforeEach(() => { vi.useRealTimers(); }); describe("initial state", () => { it("returns initial tokens for unknown account", () => { const tracker = new TokenBucketTracker(); expect(tracker.getTokens(0)).toBe(50); }); it("uses custom initial tokens from config", () => { const tracker = new TokenBucketTracker({ initialTokens: 30 }); expect(tracker.getTokens(0)).toBe(30); }); it("hasTokens returns true for new accounts", () => { const tracker = new TokenBucketTracker(); expect(tracker.hasTokens(0)).toBe(true); }); it("getMaxTokens returns configured max tokens", () => { const tracker = new TokenBucketTracker({ maxTokens: 100 }); expect(tracker.getMaxTokens()).toBe(100); }); it("getMaxTokens returns default when not configured", () => { const tracker = new TokenBucketTracker(); expect(tracker.getMaxTokens()).toBe(50); }); }); describe("consume", () => { it("reduces token balance", () => { const tracker = new TokenBucketTracker({ initialTokens: 50 }); expect(tracker.consume(0, 1)).toBe(true); // Use toBeCloseTo to handle floating point from micro-regeneration between calls expect(tracker.getTokens(0)).toBeCloseTo(49, 2); }); it("returns false when insufficient tokens", () => { const tracker = new TokenBucketTracker({ initialTokens: 5 }); expect(tracker.consume(0, 10)).toBe(false); expect(tracker.getTokens(0)).toBe(5); }); it("allows consuming exact remaining tokens", () => { const tracker = new TokenBucketTracker({ initialTokens: 10 }); expect(tracker.consume(0, 10)).toBe(true); // Use toBeCloseTo to handle floating point from micro-regeneration between calls expect(tracker.getTokens(0)).toBeCloseTo(0, 2); }); it("handles multiple consumes", () => { const tracker = new TokenBucketTracker({ initialTokens: 50 }); tracker.consume(0, 10); tracker.consume(0, 10); tracker.consume(0, 10); expect(tracker.getTokens(0)).toBe(20); }); }); describe("hasTokens", () => { it("returns true when enough tokens", () => { const tracker = new TokenBucketTracker({ initialTokens: 50 }); expect(tracker.hasTokens(0, 50)).toBe(true); }); it("returns false when insufficient tokens", () => { const tracker = new TokenBucketTracker({ initialTokens: 10 }); expect(tracker.hasTokens(0, 11)).toBe(false); }); it("defaults to cost of 1", () => { const tracker = new TokenBucketTracker({ initialTokens: 1 }); expect(tracker.hasTokens(0)).toBe(true); tracker.consume(0, 1); expect(tracker.hasTokens(0)).toBe(false); }); }); describe("refund", () => { it("adds tokens back", () => { const tracker = new TokenBucketTracker({ initialTokens: 50 }); tracker.consume(0, 10); expect(tracker.getTokens(0)).toBeCloseTo(40, 2); tracker.refund(0, 5); expect(tracker.getTokens(0)).toBeCloseTo(45, 2); }); it("caps at maxTokens", () => { const tracker = new TokenBucketTracker({ initialTokens: 50, maxTokens: 50 }); tracker.refund(0, 10); expect(tracker.getTokens(0)).toBe(50); }); }); describe("token regeneration", () => { it("regenerates tokens over time", () => { let mockTime = 0; vi.spyOn(Date, 'now').mockImplementation(() => mockTime); const tracker = new TokenBucketTracker({ initialTokens: 50, maxTokens: 50, regenerationRatePerMinute: 6 }); tracker.consume(0, 30); expect(tracker.getTokens(0)).toBe(20); mockTime = 5 * 60 * 1000; expect(tracker.getTokens(0)).toBe(50); vi.restoreAllMocks(); }); it("caps regeneration at maxTokens", () => { let mockTime = 0; vi.spyOn(Date, 'now').mockImplementation(() => mockTime); const tracker = new TokenBucketTracker({ initialTokens: 40, maxTokens: 50, regenerationRatePerMinute: 6 }); tracker.consume(0, 1); mockTime = 10 * 60 * 1000; expect(tracker.getTokens(0)).toBe(50); vi.restoreAllMocks(); }); }); }); describe("addJitter", () => { it("returns value within jitter range", () => { const base = 1000; const jitterFactor = 0.3; for (let i = 0; i < 100; i++) { const result = addJitter(base, jitterFactor); expect(result).toBeGreaterThanOrEqual(base * (1 - jitterFactor)); expect(result).toBeLessThanOrEqual(base * (1 + jitterFactor)); } }); it("uses default jitter factor of 0.3", () => { const base = 1000; for (let i = 0; i < 100; i++) { const result = addJitter(base); expect(result).toBeGreaterThanOrEqual(700); expect(result).toBeLessThanOrEqual(1300); } }); it("never returns negative values", () => { for (let i = 0; i < 100; i++) { const result = addJitter(10, 0.9); expect(result).toBeGreaterThanOrEqual(0); } }); it("returns rounded values", () => { for (let i = 0; i < 100; i++) { const result = addJitter(1000); expect(Number.isInteger(result)).toBe(true); } }); }); describe("randomDelay", () => { it("returns value within min-max range", () => { for (let i = 0; i < 100; i++) { const result = randomDelay(100, 500); expect(result).toBeGreaterThanOrEqual(100); expect(result).toBeLessThanOrEqual(500); } }); it("returns rounded values", () => { for (let i = 0; i < 100; i++) { const result = randomDelay(100, 500); expect(Number.isInteger(result)).toBe(true); } }); it("handles min === max", () => { const result = randomDelay(100, 100); expect(result).toBe(100); }); }); describe("sortByLruWithHealth", () => { it("filters out rate-limited accounts", () => { const accounts: AccountWithMetrics[] = [ { index: 0, lastUsed: 0, healthScore: 70, isRateLimited: true, isCoolingDown: false }, { index: 1, lastUsed: 0, healthScore: 70, isRateLimited: false, isCoolingDown: false }, ]; const result = sortByLruWithHealth(accounts); expect(result).toHaveLength(1); expect(result[0]?.index).toBe(1); }); it("filters out cooling down accounts", () => { const accounts: AccountWithMetrics[] = [ { index: 0, lastUsed: 0, healthScore: 70, isRateLimited: false, isCoolingDown: true }, { index: 1, lastUsed: 0, healthScore: 70, isRateLimited: false, isCoolingDown: false }, ]; const result = sortByLruWithHealth(accounts); expect(result).toHaveLength(1); expect(result[0]?.index).toBe(1); }); it("filters out unhealthy accounts", () => { const accounts: AccountWithMetrics[] = [ { index: 0, lastUsed: 0, healthScore: 40, isRateLimited: false, isCoolingDown: false }, { index: 1, lastUsed: 0, healthScore: 70, isRateLimited: false, isCoolingDown: false }, ]; const result = sortByLruWithHealth(accounts, 50); expect(result).toHaveLength(1); expect(result[0]?.index).toBe(1); }); it("sorts by lastUsed ascending (oldest first)", () => { const accounts: AccountWithMetrics[] = [ { index: 0, lastUsed: 1000, healthScore: 70, isRateLimited: false, isCoolingDown: false }, { index: 1, lastUsed: 500, healthScore: 70, isRateLimited: false, isCoolingDown: false }, { index: 2, lastUsed: 2000, healthScore: 70, isRateLimited: false, isCoolingDown: false }, ]; const result = sortByLruWithHealth(accounts); expect(result.map(a => a.index)).toEqual([1, 0, 2]); }); it("uses health score as tiebreaker", () => { const accounts: AccountWithMetrics[] = [ { index: 0, lastUsed: 1000, healthScore: 60, isRateLimited: false, isCoolingDown: false }, { index: 1, lastUsed: 1000, healthScore: 80, isRateLimited: false, isCoolingDown: false }, { index: 2, lastUsed: 1000, healthScore: 70, isRateLimited: false, isCoolingDown: false }, ]; const result = sortByLruWithHealth(accounts); expect(result.map(a => a.index)).toEqual([1, 2, 0]); }); it("returns empty array when all accounts filtered out", () => { const accounts: AccountWithMetrics[] = [ { index: 0, lastUsed: 0, healthScore: 30, isRateLimited: false, isCoolingDown: false }, { index: 1, lastUsed: 0, healthScore: 70, isRateLimited: true, isCoolingDown: false }, ]; const result = sortByLruWithHealth(accounts, 50); expect(result).toHaveLength(0); }); }); describe("selectHybridAccount", () => { it("returns null when no accounts available", () => { const tokenTracker = new TokenBucketTracker(); const result = selectHybridAccount([], tokenTracker); expect(result).toBeNull(); }); it("returns null when all accounts filtered out by health", () => { const tokenTracker = new TokenBucketTracker(); const accounts: AccountWithMetrics[] = [ { index: 0, lastUsed: 0, healthScore: 30, isRateLimited: false, isCoolingDown: false }, ]; const result = selectHybridAccount(accounts, tokenTracker, 50); expect(result).toBeNull(); }); it("returns the best candidate by score", () => { const tokenTracker = new TokenBucketTracker(); const accounts: AccountWithMetrics[] = [ { index: 0, lastUsed: 1000, healthScore: 70, isRateLimited: false, isCoolingDown: false }, { index: 1, lastUsed: 500, healthScore: 70, isRateLimited: false, isCoolingDown: false }, { index: 2, lastUsed: 2000, healthScore: 70, isRateLimited: false, isCoolingDown: false }, ]; const result = selectHybridAccount(accounts, tokenTracker); expect([0, 1, 2]).toContain(result); }); it("filters out rate-limited accounts", () => { const tokenTracker = new TokenBucketTracker(); const accounts: AccountWithMetrics[] = [ { index: 0, lastUsed: 0, healthScore: 70, isRateLimited: true, isCoolingDown: false }, { index: 1, lastUsed: 0, healthScore: 70, isRateLimited: false, isCoolingDown: false }, ]; const result = selectHybridAccount(accounts, tokenTracker); expect(result).toBe(1); }); it("filters out accounts without tokens", () => { const tokenTracker = new TokenBucketTracker({ initialTokens: 1 }); tokenTracker.consume(0, 1); const accounts: AccountWithMetrics[] = [ { index: 0, lastUsed: 0, healthScore: 70, isRateLimited: false, isCoolingDown: false }, { index: 1, lastUsed: 0, healthScore: 70, isRateLimited: false, isCoolingDown: false }, ]; const result = selectHybridAccount(accounts, tokenTracker); expect(result).toBe(1); }); it("filters out unhealthy accounts", () => { const tokenTracker = new TokenBucketTracker(); const accounts: AccountWithMetrics[] = [ { index: 0, lastUsed: 0, healthScore: 40, isRateLimited: false, isCoolingDown: false }, { index: 1, lastUsed: 0, healthScore: 70, isRateLimited: false, isCoolingDown: false }, ]; const result = selectHybridAccount(accounts, tokenTracker, 50); expect(result).toBe(1); }); it("returns null when all accounts have no tokens", () => { const tokenTracker = new TokenBucketTracker({ initialTokens: 0 }); const accounts: AccountWithMetrics[] = [ { index: 0, lastUsed: 0, healthScore: 70, isRateLimited: false, isCoolingDown: false }, ]; const result = selectHybridAccount(accounts, tokenTracker); expect(result).toBeNull(); }); it("selects only available candidate when one account is filtered", () => { const tokenTracker = new TokenBucketTracker({ initialTokens: 50 }); const accounts: AccountWithMetrics[] = [ { index: 0, lastUsed: 0, healthScore: 40, isRateLimited: false, isCoolingDown: false }, { index: 1, lastUsed: 0, healthScore: 100, isRateLimited: false, isCoolingDown: false }, ]; const result = selectHybridAccount(accounts, tokenTracker, 50); expect(result).toBe(1); }); it("returns a valid account index", () => { const tokenTracker = new TokenBucketTracker(); const accounts: AccountWithMetrics[] = [ { index: 0, lastUsed: 1000, healthScore: 70, isRateLimited: false, isCoolingDown: false }, { index: 1, lastUsed: 500, healthScore: 80, isRateLimited: false, isCoolingDown: false }, { index: 2, lastUsed: 2000, healthScore: 60, isRateLimited: false, isCoolingDown: false }, ]; for (let i = 0; i < 10; i++) { const result = selectHybridAccount(accounts, tokenTracker); expect([0, 1, 2]).toContain(result); } }); }); ================================================ FILE: src/plugin/rotation.ts ================================================ /** * Account Rotation System * * Implements advanced account selection algorithms: * - Health Score: Track account wellness based on success/failure * - LRU Selection: Prefer accounts with longest rest periods * - Jitter: Add random variance to break predictable patterns * * Used by 'hybrid' strategy for improved ban prevention and load distribution. */ // ============================================================================ // HEALTH SCORE SYSTEM // ============================================================================ export interface HealthScoreConfig { /** Initial score for new accounts (default: 70) */ initial: number; /** Points added on successful request (default: 1) */ successReward: number; /** Points removed on rate limit (default: -10) */ rateLimitPenalty: number; /** Points removed on failure (auth, network, etc.) (default: -20) */ failurePenalty: number; /** Points recovered per hour of rest (default: 2) */ recoveryRatePerHour: number; /** Minimum score to be considered usable (default: 50) */ minUsable: number; /** Maximum score cap (default: 100) */ maxScore: number; } export const DEFAULT_HEALTH_SCORE_CONFIG: HealthScoreConfig = { initial: 70, successReward: 1, rateLimitPenalty: -10, failurePenalty: -20, recoveryRatePerHour: 2, minUsable: 50, maxScore: 100, }; interface HealthScoreState { score: number; lastUpdated: number; lastSuccess: number; consecutiveFailures: number; } /** * Tracks health scores for accounts. * Higher score = healthier account = preferred for selection. */ export class HealthScoreTracker { private readonly scores = new Map(); private readonly config: HealthScoreConfig; constructor(config: Partial = {}) { this.config = { ...DEFAULT_HEALTH_SCORE_CONFIG, ...config }; } /** * Get current health score for an account, applying time-based recovery. */ getScore(accountIndex: number): number { const state = this.scores.get(accountIndex); if (!state) { return this.config.initial; } // Apply passive recovery based on time since last update const now = Date.now(); const hoursSinceUpdate = (now - state.lastUpdated) / (1000 * 60 * 60); const recoveredPoints = Math.floor(hoursSinceUpdate * this.config.recoveryRatePerHour); return Math.min( this.config.maxScore, state.score + recoveredPoints ); } /** * Record a successful request - improves health score. */ recordSuccess(accountIndex: number): void { const now = Date.now(); const current = this.getScore(accountIndex); this.scores.set(accountIndex, { score: Math.min(this.config.maxScore, current + this.config.successReward), lastUpdated: now, lastSuccess: now, consecutiveFailures: 0, }); } /** * Record a rate limit hit - moderate penalty. */ recordRateLimit(accountIndex: number): void { const now = Date.now(); const state = this.scores.get(accountIndex); const current = this.getScore(accountIndex); this.scores.set(accountIndex, { score: Math.max(0, current + this.config.rateLimitPenalty), lastUpdated: now, lastSuccess: state?.lastSuccess ?? 0, consecutiveFailures: (state?.consecutiveFailures ?? 0) + 1, }); } /** * Record a failure (auth, network, etc.) - larger penalty. */ recordFailure(accountIndex: number): void { const now = Date.now(); const state = this.scores.get(accountIndex); const current = this.getScore(accountIndex); this.scores.set(accountIndex, { score: Math.max(0, current + this.config.failurePenalty), lastUpdated: now, lastSuccess: state?.lastSuccess ?? 0, consecutiveFailures: (state?.consecutiveFailures ?? 0) + 1, }); } /** * Check if account is healthy enough to use. */ isUsable(accountIndex: number): boolean { return this.getScore(accountIndex) >= this.config.minUsable; } /** * Get consecutive failure count for an account. */ getConsecutiveFailures(accountIndex: number): number { return this.scores.get(accountIndex)?.consecutiveFailures ?? 0; } /** * Reset health state for an account (e.g., after removal). */ reset(accountIndex: number): void { this.scores.delete(accountIndex); } /** * Get all scores for debugging/logging. */ getSnapshot(): Map { const result = new Map(); for (const [index] of this.scores) { result.set(index, { score: this.getScore(index), consecutiveFailures: this.getConsecutiveFailures(index), }); } return result; } } // ============================================================================ // JITTER UTILITIES // ============================================================================ /** * Add random jitter to a delay value. * Helps break predictable timing patterns. * * @param baseMs - Base delay in milliseconds * @param jitterFactor - Fraction of base to vary (default: 0.3 = ±30%) * @returns Jittered delay in milliseconds */ export function addJitter(baseMs: number, jitterFactor: number = 0.3): number { const jitterRange = baseMs * jitterFactor; const jitter = (Math.random() * 2 - 1) * jitterRange; // -jitterRange to +jitterRange return Math.max(0, Math.round(baseMs + jitter)); } /** * Generate a random delay within a range. * * @param minMs - Minimum delay in milliseconds * @param maxMs - Maximum delay in milliseconds * @returns Random delay between min and max */ export function randomDelay(minMs: number, maxMs: number): number { return Math.round(minMs + Math.random() * (maxMs - minMs)); } // ============================================================================ // LRU SELECTION // ============================================================================ export interface AccountWithMetrics { index: number; lastUsed: number; healthScore: number; isRateLimited: boolean; isCoolingDown: boolean; } /** * Sort accounts by LRU (least recently used first) with health score tiebreaker. * * Priority: * 1. Filter out rate-limited and cooling-down accounts * 2. Filter out unhealthy accounts (score < minUsable) * 3. Sort by lastUsed ascending (oldest first = most rested) * 4. Tiebreaker: higher health score wins */ export function sortByLruWithHealth( accounts: AccountWithMetrics[], minHealthScore: number = 50, ): AccountWithMetrics[] { return accounts .filter(acc => !acc.isRateLimited && !acc.isCoolingDown && acc.healthScore >= minHealthScore) .sort((a, b) => { // Primary: LRU (oldest lastUsed first) const lruDiff = a.lastUsed - b.lastUsed; if (lruDiff !== 0) return lruDiff; // Tiebreaker: higher health score wins return b.healthScore - a.healthScore; }); } /** Stickiness bonus added to current account's score to prevent unnecessary switching */ const STICKINESS_BONUS = 150; /** Minimum score advantage required to switch away from current account */ const SWITCH_THRESHOLD = 100; /** * Select account using hybrid strategy with stickiness: * 1. Filter available accounts (not rate-limited, not cooling down, healthy, has tokens) * 2. Calculate priority score: health (2x) + tokens (5x) + freshness (0.1x) * 3. Apply stickiness bonus to current account * 4. Only switch if another account beats current by SWITCH_THRESHOLD * * @param accounts - All accounts with their metrics * @param tokenTracker - Token bucket tracker for token balances * @param currentAccountIndex - Currently active account index (for stickiness) * @param minHealthScore - Minimum health score to be considered * @returns Best account index, or null if none available */ export function selectHybridAccount( accounts: AccountWithMetrics[], tokenTracker: TokenBucketTracker, currentAccountIndex: number | null = null, minHealthScore: number = 50, ): number | null { const candidates = accounts .filter(acc => !acc.isRateLimited && !acc.isCoolingDown && acc.healthScore >= minHealthScore && tokenTracker.hasTokens(acc.index) ) .map(acc => ({ ...acc, tokens: tokenTracker.getTokens(acc.index) })); if (candidates.length === 0) { return null; } const maxTokens = tokenTracker.getMaxTokens(); const scored = candidates .map(acc => { const baseScore = calculateHybridScore(acc, maxTokens); // Apply stickiness bonus to current account const stickinessBonus = acc.index === currentAccountIndex ? STICKINESS_BONUS : 0; return { index: acc.index, baseScore, score: baseScore + stickinessBonus, isCurrent: acc.index === currentAccountIndex }; }) .sort((a, b) => b.score - a.score); const best = scored[0]; if (!best) { return null; } // If current account is still a candidate, check if switch is warranted const currentCandidate = scored.find(s => s.isCurrent); if (currentCandidate && !best.isCurrent) { // Only switch if best beats current's BASE score by threshold // (compare base scores to avoid circular stickiness bonus comparison) const advantage = best.baseScore - currentCandidate.baseScore; if (advantage < SWITCH_THRESHOLD) { return currentCandidate.index; } } return best.index; } interface AccountWithTokens extends AccountWithMetrics { tokens: number; } function calculateHybridScore( account: AccountWithTokens, maxTokens: number ): number { const healthComponent = account.healthScore * 2; // 0-200 const tokenComponent = (account.tokens / maxTokens) * 100 * 5; // 0-500 const secondsSinceUsed = (Date.now() - account.lastUsed) / 1000; const freshnessComponent = Math.min(secondsSinceUsed, 3600) * 0.1; // 0-360 return Math.max(0, healthComponent + tokenComponent + freshnessComponent); } // ============================================================================ // TOKEN BUCKET SYSTEM // ============================================================================ export interface TokenBucketConfig { /** Maximum tokens per account (default: 50) */ maxTokens: number; /** Tokens regenerated per minute (default: 6) */ regenerationRatePerMinute: number; /** Initial tokens for new accounts (default: 50) */ initialTokens: number; } export const DEFAULT_TOKEN_BUCKET_CONFIG: TokenBucketConfig = { maxTokens: 50, regenerationRatePerMinute: 6, initialTokens: 50, }; interface TokenBucketState { tokens: number; lastUpdated: number; } /** * Client-side rate limiting using Token Bucket algorithm. * Helps prevent hitting server 429s by tracking "cost" of requests. */ export class TokenBucketTracker { private readonly buckets = new Map(); private readonly config: TokenBucketConfig; constructor(config: Partial = {}) { this.config = { ...DEFAULT_TOKEN_BUCKET_CONFIG, ...config }; } /** * Get current token balance for an account, applying regeneration. */ getTokens(accountIndex: number): number { const state = this.buckets.get(accountIndex); if (!state) { return this.config.initialTokens; } const now = Date.now(); const minutesSinceUpdate = (now - state.lastUpdated) / (1000 * 60); const recoveredTokens = minutesSinceUpdate * this.config.regenerationRatePerMinute; return Math.min( this.config.maxTokens, state.tokens + recoveredTokens ); } /** * Check if account has enough tokens for a request. * @param cost Cost of the request (default: 1) */ hasTokens(accountIndex: number, cost: number = 1): boolean { return this.getTokens(accountIndex) >= cost; } /** * Consume tokens for a request. * @returns true if tokens were consumed, false if insufficient */ consume(accountIndex: number, cost: number = 1): boolean { const current = this.getTokens(accountIndex); if (current < cost) { return false; } this.buckets.set(accountIndex, { tokens: current - cost, lastUpdated: Date.now(), }); return true; } /** * Refund tokens (e.g., if request wasn't actually sent). */ refund(accountIndex: number, amount: number = 1): void { const current = this.getTokens(accountIndex); this.buckets.set(accountIndex, { tokens: Math.min(this.config.maxTokens, current + amount), lastUpdated: Date.now(), }); } getMaxTokens(): number { return this.config.maxTokens; } } // ============================================================================ // SINGLETON TRACKERS // ============================================================================ let globalTokenTracker: TokenBucketTracker | null = null; export function getTokenTracker(): TokenBucketTracker { if (!globalTokenTracker) { globalTokenTracker = new TokenBucketTracker(); } return globalTokenTracker; } export function initTokenTracker(config: Partial): TokenBucketTracker { globalTokenTracker = new TokenBucketTracker(config); return globalTokenTracker; } let globalHealthTracker: HealthScoreTracker | null = null; /** * Get the global health score tracker instance. * Creates one with default config if not initialized. */ export function getHealthTracker(): HealthScoreTracker { if (!globalHealthTracker) { globalHealthTracker = new HealthScoreTracker(); } return globalHealthTracker; } /** * Initialize the global health tracker with custom config. * Call this at plugin startup if custom config is needed. */ export function initHealthTracker(config: Partial): HealthScoreTracker { globalHealthTracker = new HealthScoreTracker(config); return globalHealthTracker; } ================================================ FILE: src/plugin/search.ts ================================================ /** * Google Search Tool Implementation * * Due to Gemini API limitations, native search tools (googleSearch, urlContext) * cannot be combined with function declarations. This module implements a * wrapper that makes separate API calls with only the grounding tools enabled. */ import { ANTIGRAVITY_ENDPOINT, getAntigravityHeaders, SEARCH_MODEL, SEARCH_TIMEOUT_MS, SEARCH_SYSTEM_INSTRUCTION, } from "../constants"; import { createLogger } from "./logger"; const log = createLogger("search"); // ============================================================================ // Types // ============================================================================ interface GroundingChunk { web?: { uri?: string; title?: string; }; } interface GroundingSupport { segment?: { startIndex?: number; endIndex?: number; text?: string; }; groundingChunkIndices?: number[]; } interface GroundingMetadata { webSearchQueries?: string[]; groundingChunks?: GroundingChunk[]; groundingSupports?: GroundingSupport[]; searchEntryPoint?: { renderedContent?: string; }; } interface UrlMetadata { retrieved_url?: string; url_retrieval_status?: string; } interface UrlContextMetadata { url_metadata?: UrlMetadata[]; } interface SearchResponse { candidates?: Array<{ content?: { parts?: Array<{ text?: string }>; role?: string; }; finishReason?: string; groundingMetadata?: GroundingMetadata; urlContextMetadata?: UrlContextMetadata; }>; error?: { code?: number; message?: string; status?: string; }; } interface AntigravitySearchResponse { response?: SearchResponse; error?: { code?: number; message?: string; status?: string; }; } export interface SearchArgs { query: string; urls?: string[]; thinking?: boolean; } export interface SearchResult { text: string; sources: Array<{ title: string; url: string }>; searchQueries: string[]; urlsRetrieved: Array<{ url: string; status: string }>; } // ============================================================================ // Helper Functions // ============================================================================ let sessionCounter = 0; const sessionPrefix = `search-${Date.now().toString(36)}`; function generateRequestId(): string { return `search-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; } function getSessionId(): string { sessionCounter++; return `${sessionPrefix}-${sessionCounter}`; } function formatSearchResult(result: SearchResult): string { const lines: string[] = []; lines.push("## Search Results\n"); lines.push(result.text); lines.push(""); if (result.sources.length > 0) { lines.push("### Sources"); for (const source of result.sources) { lines.push(`- [${source.title}](${source.url})`); } lines.push(""); } if (result.urlsRetrieved.length > 0) { lines.push("### URLs Retrieved"); for (const url of result.urlsRetrieved) { const status = url.status === "URL_RETRIEVAL_STATUS_SUCCESS" ? "✓" : "✗"; lines.push(`- ${status} ${url.url}`); } lines.push(""); } if (result.searchQueries.length > 0) { lines.push("### Search Queries Used"); for (const q of result.searchQueries) { lines.push(`- "${q}"`); } } return lines.join("\n"); } function parseSearchResponse(data: AntigravitySearchResponse): SearchResult { const result: SearchResult = { text: "", sources: [], searchQueries: [], urlsRetrieved: [], }; const response = data.response; if (!response || !response.candidates || response.candidates.length === 0) { if (data.error) { result.text = `Error: ${data.error.message ?? "Unknown error"}`; } else if (response?.error) { result.text = `Error: ${response.error.message ?? "Unknown error"}`; } return result; } const candidate = response.candidates[0]; if (!candidate) { return result; } // Extract text content if (candidate.content?.parts) { result.text = candidate.content.parts .map((p: { text?: string }) => p.text ?? "") .filter(Boolean) .join("\n"); } // Extract grounding metadata if (candidate.groundingMetadata) { const gm = candidate.groundingMetadata; if (gm.webSearchQueries) { result.searchQueries = gm.webSearchQueries; } if (gm.groundingChunks) { for (const chunk of gm.groundingChunks) { if (chunk.web?.uri && chunk.web?.title) { result.sources.push({ title: chunk.web.title, url: chunk.web.uri, }); } } } } // Extract URL context metadata if (candidate.urlContextMetadata?.url_metadata) { for (const meta of candidate.urlContextMetadata.url_metadata) { if (meta.retrieved_url) { result.urlsRetrieved.push({ url: meta.retrieved_url, status: meta.url_retrieval_status ?? "UNKNOWN", }); } } } return result; } // ============================================================================ // Main Search Function // ============================================================================ /** * Execute a Google Search using the Gemini grounding API. * * This makes a SEPARATE API call with only googleSearch/urlContext tools, * which is required because these tools cannot be combined with function declarations. */ export async function executeSearch( args: SearchArgs, accessToken: string, projectId: string, abortSignal?: AbortSignal, ): Promise { const { query, urls, thinking = true } = args; // Build prompt with optional URLs let prompt = query; if (urls && urls.length > 0) { const urlList = urls.join("\n"); prompt = `${query}\n\nURLs to analyze:\n${urlList}`; } // Build tools array - only grounding tools, no function declarations const tools: Array> = []; tools.push({ googleSearch: {} }); if (urls && urls.length > 0) { tools.push({ urlContext: {} }); } const requestPayload = { systemInstruction: { parts: [{ text: SEARCH_SYSTEM_INSTRUCTION }], }, contents: [ { role: "user", parts: [{ text: prompt }], }, ], tools, generationConfig: { temperature: 0, topP: 1, }, }; // Wrap in Antigravity format const wrappedBody = { project: projectId, model: SEARCH_MODEL, userAgent: "antigravity", requestId: generateRequestId(), request: { ...requestPayload, sessionId: getSessionId(), }, }; // Use non-streaming endpoint for search const url = `${ANTIGRAVITY_ENDPOINT}/v1internal:generateContent`; log.debug("Executing search", { query, urlCount: urls?.length ?? 0, thinking, }); try { const response = await fetch(url, { method: "POST", headers: { ...getAntigravityHeaders(), Authorization: `Bearer ${accessToken}`, "Content-Type": "application/json", }, body: JSON.stringify(wrappedBody), signal: abortSignal ?? AbortSignal.timeout(SEARCH_TIMEOUT_MS), }); if (!response.ok) { const errorText = await response.text(); log.debug("Search API error", { status: response.status, error: errorText }); return `## Search Error\n\nFailed to execute search: ${response.status} ${response.statusText}\n\n${errorText}\n\nPlease try again with a different query.`; } const data = (await response.json()) as AntigravitySearchResponse; log.debug("Search response received", { hasResponse: !!data.response }); const result = parseSearchResponse(data); const formatted = formatSearchResult(result); log.debug("Search response formatted", { resultLength: formatted.length }); return formatted; } catch (error) { const message = error instanceof Error ? error.message : String(error); log.debug("Search execution error", { error: message }); return `## Search Error\n\nFailed to execute search: ${message}. Please try again with a different query.`; } } ================================================ FILE: src/plugin/server.ts ================================================ import { createServer } from "node:http"; import { readFileSync, existsSync } from "node:fs"; import { ANTIGRAVITY_REDIRECT_URI } from "../constants"; interface OAuthListenerOptions { /** * How long to wait for the OAuth redirect before timing out (in milliseconds). */ timeoutMs?: number; } export interface OAuthListener { /** * Resolves with the callback URL once Google redirects back to the local server. */ waitForCallback(): Promise; /** * Cleanly stop listening for callbacks. */ close(): Promise; } const redirectUri = new URL(ANTIGRAVITY_REDIRECT_URI); const callbackPath = redirectUri.pathname || "/"; /** * Detect if running in OrbStack Docker with --network host mode. * OrbStack's host networking only forwards ports bound to 127.0.0.1 to macOS. */ function isOrbStackDockerHost(): boolean { // Check if we're in Docker if (!existsSync("/.dockerenv")) { return false; } // Check for OrbStack-specific indicators // OrbStack sets specific environment variables or has identifiable characteristics try { // OrbStack containers often have /run/.containerenv or specific mount patterns // Also check if /proc/version contains orbstack if (existsSync("/proc/version")) { const version = readFileSync("/proc/version", "utf8").toLowerCase(); if (version.includes("orbstack")) { return true; } } // Check hostname pattern (OrbStack uses specific patterns) const hostname = process.env.HOSTNAME || ""; if (hostname.startsWith("orbstack-") || hostname.endsWith(".orb") || hostname === "orbstack") { return true; } // Check for OrbStack's network host mode by looking at resolv.conf // OrbStack with --network host has specific DNS configuration if (existsSync("/etc/resolv.conf")) { const resolv = readFileSync("/etc/resolv.conf", "utf8"); if (resolv.includes("orb.local") || resolv.includes("orbstack")) { return true; } } // Fallback: Check if running on macOS/Darwin host via Docker // This is a heuristic - if in Docker on Linux but /proc/version shows darwin-like patterns if (process.platform === "linux" && existsSync("/.dockerenv")) { // Most OrbStack containers will have been caught above // For safety, also check common OrbStack mount patterns if (existsSync("/run/host-services")) { return true; } } } catch { // Ignore errors, fall through to default } return false; } /** * Detect WSL (Windows Subsystem for Linux) environment. */ function isWSL(): boolean { if (process.platform !== "linux") return false; try { const release = readFileSync("/proc/version", "utf8").toLowerCase(); return release.includes("microsoft") || release.includes("wsl"); } catch { return false; } } /** * Detect remote/SSH environment where localhost may not be accessible from browser. */ function isRemoteEnvironment(): boolean { if (process.env.SSH_CLIENT || process.env.SSH_TTY || process.env.SSH_CONNECTION) { return true; } if (process.env.REMOTE_CONTAINERS || process.env.CODESPACES) { return true; } return false; } /** * Determine the best bind address for the OAuth callback server. * * Priority: * 1. OPENCODE_ANTIGRAVITY_OAUTH_BIND environment variable (user override) * 2. OrbStack Docker with --network host: 127.0.0.1 (required for port forwarding) * 3. WSL/SSH/Remote: 0.0.0.0 (needed for cross-network access) * 4. Default: 127.0.0.1 (most secure for local development) */ function getBindAddress(): string { // Allow user override via environment variable const envBind = process.env.OPENCODE_ANTIGRAVITY_OAUTH_BIND; if (envBind) { return envBind; } // OrbStack Docker needs 127.0.0.1 for --network host port forwarding if (isOrbStackDockerHost()) { return "127.0.0.1"; } // WSL and remote environments need 0.0.0.0 to be reachable if (isWSL() || isRemoteEnvironment()) { return "0.0.0.0"; } // Default to 127.0.0.1 for security (local-only access) return "127.0.0.1"; } /** * Starts a lightweight HTTP server that listens for the Antigravity OAuth redirect * and resolves with the captured callback URL. */ export async function startOAuthListener( { timeoutMs = 5 * 60 * 1000 }: OAuthListenerOptions = {}, ): Promise { const port = redirectUri.port ? Number.parseInt(redirectUri.port, 10) : redirectUri.protocol === "https:" ? 443 : 80; const origin = `${redirectUri.protocol}//${redirectUri.host}`; let settled = false; let resolveCallback: (url: URL) => void; let rejectCallback: (error: Error) => void; let timeoutHandle: NodeJS.Timeout; const callbackPromise = new Promise((resolve, reject) => { resolveCallback = (url: URL) => { if (settled) return; settled = true; if (timeoutHandle) clearTimeout(timeoutHandle); resolve(url); }; rejectCallback = (error: Error) => { if (settled) return; settled = true; if (timeoutHandle) clearTimeout(timeoutHandle); reject(error); }; }); const successResponse = ` Authentication Successful

All set!

You've successfully authenticated with Antigravity. You can now return to Opencode.

Usage Tip: Most browsers block auto-closing. If the button doesn't work, please close the tab manually.
`; timeoutHandle = setTimeout(() => { rejectCallback(new Error("Timed out waiting for OAuth callback")); }, timeoutMs); timeoutHandle.unref?.(); const server = createServer((request, response) => { if (!request.url) { response.writeHead(400, { "Content-Type": "text/plain" }); response.end("Invalid request"); return; } const url = new URL(request.url, origin); if (url.pathname !== callbackPath) { response.writeHead(404, { "Content-Type": "text/plain" }); response.end("Not found"); return; } response.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }); response.end(successResponse); resolveCallback(url); setImmediate(() => { server.close(); }); }); const bindAddress = getBindAddress(); await new Promise((resolve, reject) => { const handleError = (error: NodeJS.ErrnoException) => { server.off("error", handleError); if (error.code === "EADDRINUSE") { reject(new Error( `Port ${port} is already in use. ` + `Another process is occupying this port. ` + `Please terminate the process or try again later.` )); return; } reject(error); }; server.once("error", handleError); server.listen(port, bindAddress, () => { server.off("error", handleError); resolve(); }); }); server.on("error", (error) => { rejectCallback(error instanceof Error ? error : new Error(String(error))); }); return { waitForCallback: () => callbackPromise, close: () => new Promise((resolve, reject) => { server.close((error) => { if (error && (error as NodeJS.ErrnoException).code !== "ERR_SERVER_NOT_RUNNING") { reject(error); return; } if (!settled) { rejectCallback(new Error("OAuth listener closed before callback")); } resolve(); }); }), }; } ================================================ FILE: src/plugin/storage.test.ts ================================================ import { describe, expect, it, vi, beforeEach } from "vitest"; import { deduplicateAccountsByEmail, migrateV2ToV3, loadAccounts, type AccountMetadata, type AccountStorage, } from "./storage"; import { promises as fs } from "node:fs"; import { existsSync, readFileSync, writeFileSync, appendFileSync, } from "node:fs"; vi.mock("proper-lockfile", () => ({ default: { lock: vi.fn().mockResolvedValue(vi.fn().mockResolvedValue(undefined)), }, })); describe("deduplicateAccountsByEmail", () => { it("returns empty array for empty input", () => { const result = deduplicateAccountsByEmail([]); expect(result).toEqual([]); }); it("returns single account unchanged", () => { const accounts: AccountMetadata[] = [ { email: "test@example.com", refreshToken: "r1", addedAt: 1000, lastUsed: 2000, }, ]; const result = deduplicateAccountsByEmail(accounts); expect(result).toEqual(accounts); }); it("keeps accounts without email (cannot deduplicate)", () => { const accounts: AccountMetadata[] = [ { refreshToken: "r1", addedAt: 1000, lastUsed: 2000 }, { refreshToken: "r2", addedAt: 1100, lastUsed: 2100 }, ]; const result = deduplicateAccountsByEmail(accounts); expect(result).toHaveLength(2); expect(result[0]?.refreshToken).toBe("r1"); expect(result[1]?.refreshToken).toBe("r2"); }); it("deduplicates accounts with same email, keeping newest by lastUsed", () => { const accounts: AccountMetadata[] = [ { email: "test@example.com", refreshToken: "old-token", addedAt: 1000, lastUsed: 1000, }, { email: "test@example.com", refreshToken: "new-token", addedAt: 2000, lastUsed: 3000, }, ]; const result = deduplicateAccountsByEmail(accounts); expect(result).toHaveLength(1); expect(result[0]?.refreshToken).toBe("new-token"); expect(result[0]?.email).toBe("test@example.com"); }); it("deduplicates accounts with same email, keeping newest by addedAt when lastUsed is equal", () => { const accounts: AccountMetadata[] = [ { email: "test@example.com", refreshToken: "old-token", addedAt: 1000, lastUsed: 0, }, { email: "test@example.com", refreshToken: "new-token", addedAt: 2000, lastUsed: 0, }, ]; const result = deduplicateAccountsByEmail(accounts); expect(result).toHaveLength(1); expect(result[0]?.refreshToken).toBe("new-token"); }); it("handles multiple duplicate emails correctly", () => { const accounts: AccountMetadata[] = [ { email: "alice@example.com", refreshToken: "alice-old", addedAt: 1000, lastUsed: 1000, }, { email: "bob@example.com", refreshToken: "bob-old", addedAt: 1000, lastUsed: 1000, }, { email: "alice@example.com", refreshToken: "alice-new", addedAt: 2000, lastUsed: 3000, }, { email: "bob@example.com", refreshToken: "bob-new", addedAt: 2000, lastUsed: 3000, }, { email: "alice@example.com", refreshToken: "alice-mid", addedAt: 1500, lastUsed: 2000, }, ]; const result = deduplicateAccountsByEmail(accounts); expect(result).toHaveLength(2); const alice = result.find((a) => a.email === "alice@example.com"); const bob = result.find((a) => a.email === "bob@example.com"); expect(alice?.refreshToken).toBe("alice-new"); expect(bob?.refreshToken).toBe("bob-new"); }); it("preserves order of kept accounts based on newest entry index", () => { const accounts: AccountMetadata[] = [ { email: "first@example.com", refreshToken: "first-old", addedAt: 1000, lastUsed: 1000, }, { email: "second@example.com", refreshToken: "second-new", addedAt: 3000, lastUsed: 3000, }, { email: "first@example.com", refreshToken: "first-new", addedAt: 2000, lastUsed: 2000, }, ]; const result = deduplicateAccountsByEmail(accounts); expect(result).toHaveLength(2); // Kept entries are at indices 1 (second@) and 2 (first@), so order is second, first expect(result[0]?.email).toBe("second@example.com"); expect(result[1]?.email).toBe("first@example.com"); }); it("mixes accounts with and without email correctly", () => { const accounts: AccountMetadata[] = [ { email: "test@example.com", refreshToken: "r1", addedAt: 1000, lastUsed: 1000, }, { refreshToken: "no-email-1", addedAt: 1500, lastUsed: 1500 }, { email: "test@example.com", refreshToken: "r2", addedAt: 2000, lastUsed: 2000, }, { refreshToken: "no-email-2", addedAt: 2500, lastUsed: 2500 }, ]; const result = deduplicateAccountsByEmail(accounts); expect(result).toHaveLength(3); // no-email-1 at index 1 // r2 (newest for test@example.com) at index 2 // no-email-2 at index 3 expect(result[0]?.refreshToken).toBe("no-email-1"); expect(result[1]?.refreshToken).toBe("r2"); expect(result[2]?.refreshToken).toBe("no-email-2"); }); it("handles exact scenario from issue #24 (11 duplicate accounts)", () => { // Simulate user logging in 11 times with the same account const accounts: AccountMetadata[] = []; for (let i = 0; i < 11; i++) { accounts.push({ email: "user@example.com", refreshToken: `token-${i}`, addedAt: 1000 + i * 100, lastUsed: 1000 + i * 100, }); } const result = deduplicateAccountsByEmail(accounts); expect(result).toHaveLength(1); expect(result[0]?.refreshToken).toBe("token-10"); // The newest one expect(result[0]?.email).toBe("user@example.com"); }); }); vi.mock("node:fs", async () => { const actual = await vi.importActual("node:fs"); return { ...actual, promises: { ...actual.promises, readFile: vi.fn(), writeFile: vi.fn(), mkdir: vi.fn().mockResolvedValue(undefined), access: vi.fn().mockResolvedValue(undefined), unlink: vi.fn(), rename: vi.fn().mockResolvedValue(undefined), appendFile: vi.fn(), }, existsSync: vi.fn(), readFileSync: vi.fn(), writeFileSync: vi.fn(), appendFileSync: vi.fn(), }; }); describe("Storage Migration", () => { const now = Date.now(); const future = now + 100000; const past = now - 100000; describe("migrateV2ToV3", () => { it("converts gemini rate limits to gemini-antigravity", () => { const v2: AccountStorage = { version: 2, accounts: [ { refreshToken: "r1", addedAt: now, lastUsed: now, rateLimitResetTimes: { gemini: future, }, }, ], activeIndex: 0, }; const v3 = migrateV2ToV3(v2); expect(v3.version).toBe(3); const account = v3.accounts[0]; if (!account) throw new Error("Account not found"); expect(account.rateLimitResetTimes).toEqual({ "gemini-antigravity": future, }); expect(account.rateLimitResetTimes?.["gemini-cli"]).toBeUndefined(); }); it("preserves claude rate limits", () => { const v2: AccountStorage = { version: 2, accounts: [ { refreshToken: "r1", addedAt: now, lastUsed: now, rateLimitResetTimes: { claude: future, }, }, ], activeIndex: 0, }; const v3 = migrateV2ToV3(v2); const account = v3.accounts[0]; if (!account) throw new Error("Account not found"); expect(account.rateLimitResetTimes).toEqual({ claude: future, }); }); it("handles mixed rate limits correctly", () => { const v2: AccountStorage = { version: 2, accounts: [ { refreshToken: "r1", addedAt: now, lastUsed: now, rateLimitResetTimes: { claude: future, gemini: future, }, }, ], activeIndex: 0, }; const v3 = migrateV2ToV3(v2); const account = v3.accounts[0]; if (!account) throw new Error("Account not found"); expect(account.rateLimitResetTimes).toEqual({ claude: future, "gemini-antigravity": future, }); }); it("filters out expired rate limits", () => { const v2: AccountStorage = { version: 2, accounts: [ { refreshToken: "r1", addedAt: now, lastUsed: now, rateLimitResetTimes: { claude: past, gemini: future, }, }, ], activeIndex: 0, }; const v3 = migrateV2ToV3(v2); const account = v3.accounts[0]; if (!account) throw new Error("Account not found"); expect(account.rateLimitResetTimes).toEqual({ "gemini-antigravity": future, }); expect(account.rateLimitResetTimes?.claude).toBeUndefined(); }); it("removes rateLimitResetTimes object if all keys are expired", () => { const v2: AccountStorage = { version: 2, accounts: [ { refreshToken: "r1", addedAt: now, lastUsed: now, rateLimitResetTimes: { claude: past, gemini: past, }, }, ], activeIndex: 0, }; const v3 = migrateV2ToV3(v2); const account = v3.accounts[0]; if (!account) throw new Error("Account not found"); expect(account.rateLimitResetTimes).toBeUndefined(); }); }); describe("loadAccounts migration integration", () => { beforeEach(() => { vi.clearAllMocks(); }); it("migrates V2 storage on load and persists V4", async () => { const v2Data = { version: 2, accounts: [ { refreshToken: "r1", addedAt: now, lastUsed: now, rateLimitResetTimes: { gemini: future, }, }, ], activeIndex: 0, }; // Mock readFile to return different values based on path vi.mocked(fs.readFile).mockImplementation((path) => { if ((path as string).endsWith(".gitignore")) { const error = new Error("ENOENT") as NodeJS.ErrnoException; error.code = "ENOENT"; return Promise.reject(error); } return Promise.resolve(JSON.stringify(v2Data)); }); const result = await loadAccounts(); expect(result).not.toBeNull(); expect(result?.version).toBe(4); const account = result?.accounts[0]; if (!account) throw new Error("Account not found"); expect(account.rateLimitResetTimes).toEqual({ "gemini-antigravity": future, }); expect(fs.writeFile).toHaveBeenCalled(); const saveCall = vi.mocked(fs.writeFile).mock.calls.find( (call) => (call[0] as string).includes(".tmp") ); if (!saveCall) throw new Error("saveAccounts was not called (tmp file not found)"); const savedContent = JSON.parse(saveCall[1] as string); expect(savedContent.version).toBe(4); expect(savedContent.accounts[0].rateLimitResetTimes).toEqual({ "gemini-antigravity": future, }); const gitignoreCall = vi.mocked(fs.writeFile).mock.calls.find( (call) => (call[0] as string).includes(".gitignore") ); expect(gitignoreCall).toBeDefined(); }); }); describe("ensureGitignore", () => { const configDir = "/tmp/opencode-test"; beforeEach(() => { vi.clearAllMocks(); }); it("creates .gitignore when file does not exist", async () => { vi.mocked(fs.readFile).mockRejectedValue({ code: "ENOENT" }); const { ensureGitignore } = await import("./storage"); await ensureGitignore(configDir); expect(fs.writeFile).toHaveBeenCalled(); const [path, content] = vi.mocked(fs.writeFile).mock.calls[0]!; expect(path).toContain(".gitignore"); expect(content).toContain("antigravity-accounts.json"); expect(content).toContain("antigravity-signature-cache.json"); expect(content).toContain("antigravity-logs/"); }); it("appends missing entries to existing .gitignore", async () => { vi.mocked(fs.readFile).mockResolvedValue("existing-entry"); const { ensureGitignore } = await import("./storage"); await ensureGitignore(configDir); expect(fs.appendFile).toHaveBeenCalled(); const [path, content] = vi.mocked(fs.appendFile).mock.calls[0]!; expect(path).toContain(".gitignore"); expect(content).toContain("antigravity-accounts.json"); expect((content as string).startsWith("\n")).toBe(true); }); it("does nothing when all entries already exist", async () => { const existing = [ ".gitignore", "antigravity-accounts.json", "antigravity-accounts.json.*.tmp", "antigravity-signature-cache.json", "antigravity-logs/", ].join("\n"); vi.mocked(fs.readFile).mockResolvedValue(existing); const { ensureGitignore } = await import("./storage"); await ensureGitignore(configDir); expect(fs.writeFile).not.toHaveBeenCalled(); expect(fs.appendFile).not.toHaveBeenCalled(); }); it("handles permission errors gracefully", async () => { vi.mocked(fs.readFile).mockRejectedValue({ code: "EACCES" }); const { ensureGitignore } = await import("./storage"); await expect(ensureGitignore(configDir)).resolves.not.toThrow(); expect(fs.writeFile).not.toHaveBeenCalled(); expect(fs.appendFile).not.toHaveBeenCalled(); }); }); describe("ensureGitignoreSync", () => { const configDir = "/tmp/opencode-test-sync"; beforeEach(() => { vi.clearAllMocks(); }); it("creates .gitignore when file does not exist", async () => { vi.mocked(existsSync).mockReturnValue(false); const { ensureGitignoreSync } = await import("./storage"); ensureGitignoreSync(configDir); expect(writeFileSync).toHaveBeenCalled(); const [path, content] = vi.mocked(writeFileSync).mock.calls[0]!; expect(path).toContain(".gitignore"); expect(content).toContain("antigravity-accounts.json"); expect(content).toContain("antigravity-signature-cache.json"); expect(content).toContain("antigravity-logs/"); }); it("appends missing entries to existing .gitignore", async () => { vi.mocked(existsSync).mockReturnValue(true); vi.mocked(readFileSync).mockReturnValue("existing-entry"); const { ensureGitignoreSync } = await import("./storage"); ensureGitignoreSync(configDir); expect(appendFileSync).toHaveBeenCalled(); const [path, content] = vi.mocked(appendFileSync).mock.calls[0]!; expect(path).toContain(".gitignore"); expect(content).toContain("antigravity-accounts.json"); expect((content as string).startsWith("\n")).toBe(true); }); it("does nothing when all entries already exist", async () => { vi.mocked(existsSync).mockReturnValue(true); const existing = [ ".gitignore", "antigravity-accounts.json", "antigravity-accounts.json.*.tmp", "antigravity-signature-cache.json", "antigravity-logs/", ].join("\n"); vi.mocked(readFileSync).mockReturnValue(existing); const { ensureGitignoreSync } = await import("./storage"); ensureGitignoreSync(configDir); expect(writeFileSync).not.toHaveBeenCalled(); expect(appendFileSync).not.toHaveBeenCalled(); }); }); }); ================================================ FILE: src/plugin/storage.ts ================================================ import { promises as fs } from "node:fs"; import { existsSync, readFileSync, writeFileSync, appendFileSync, mkdirSync, renameSync, copyFileSync, unlinkSync, } from "node:fs"; import { dirname, join } from "node:path"; import { homedir } from "node:os"; import { randomBytes } from "node:crypto"; import lockfile from "proper-lockfile"; import type { HeaderStyle } from "../constants"; import { createLogger } from "./logger"; const log = createLogger("storage"); /** * Files/directories that should be gitignored in the config directory. * These contain sensitive data or machine-specific state. */ export const GITIGNORE_ENTRIES = [ ".gitignore", "antigravity-accounts.json", "antigravity-accounts.json.*.tmp", "antigravity-signature-cache.json", "antigravity-logs/", ]; /** * Ensures a .gitignore file exists in the config directory with entries * for sensitive files. Creates the file if missing, or appends missing * entries if it already exists. */ export async function ensureGitignore(configDir: string): Promise { const gitignorePath = join(configDir, ".gitignore"); try { let content: string; let existingLines: string[] = []; try { content = await fs.readFile(gitignorePath, "utf-8"); existingLines = content.split("\n").map((line) => line.trim()); } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") { return; } content = ""; } const missingEntries = GITIGNORE_ENTRIES.filter( (entry) => !existingLines.includes(entry), ); if (missingEntries.length === 0) { return; } if (content === "") { await fs.writeFile( gitignorePath, missingEntries.join("\n") + "\n", "utf-8", ); log.info("Created .gitignore in config directory"); } else { const suffix = content.endsWith("\n") ? "" : "\n"; await fs.appendFile( gitignorePath, suffix + missingEntries.join("\n") + "\n", "utf-8", ); log.info("Updated .gitignore with missing entries", { added: missingEntries, }); } } catch { // Non-critical feature } } /** * Synchronous version of ensureGitignore for use in sync code paths. */ export function ensureGitignoreSync(configDir: string): void { const gitignorePath = join(configDir, ".gitignore"); try { let content: string; let existingLines: string[] = []; if (existsSync(gitignorePath)) { content = readFileSync(gitignorePath, "utf-8"); existingLines = content.split("\n").map((line) => line.trim()); } else { content = ""; } const missingEntries = GITIGNORE_ENTRIES.filter( (entry) => !existingLines.includes(entry), ); if (missingEntries.length === 0) { return; } if (content === "") { writeFileSync(gitignorePath, missingEntries.join("\n") + "\n", "utf-8"); log.info("Created .gitignore in config directory"); } else { const suffix = content.endsWith("\n") ? "" : "\n"; appendFileSync( gitignorePath, suffix + missingEntries.join("\n") + "\n", "utf-8", ); log.info("Updated .gitignore with missing entries", { added: missingEntries, }); } } catch { // Non-critical feature } } export type ModelFamily = "claude" | "gemini"; export type { HeaderStyle }; export interface RateLimitState { claude?: number; gemini?: number; } export interface RateLimitStateV3 { claude?: number; "gemini-antigravity"?: number; "gemini-cli"?: number; [key: string]: number | undefined; } export interface AccountMetadataV1 { email?: string; refreshToken: string; projectId?: string; managedProjectId?: string; addedAt: number; lastUsed: number; isRateLimited?: boolean; rateLimitResetTime?: number; lastSwitchReason?: "rate-limit" | "initial" | "rotation"; } export interface AccountStorageV1 { version: 1; accounts: AccountMetadataV1[]; activeIndex: number; } export interface AccountMetadata { email?: string; refreshToken: string; projectId?: string; managedProjectId?: string; addedAt: number; lastUsed: number; lastSwitchReason?: "rate-limit" | "initial" | "rotation"; rateLimitResetTimes?: RateLimitState; } export interface AccountStorage { version: 2; accounts: AccountMetadata[]; activeIndex: number; } export type CooldownReason = "auth-failure" | "network-error" | "project-error" | "validation-required"; export interface AccountMetadataV3 { email?: string; refreshToken: string; projectId?: string; managedProjectId?: string; addedAt: number; lastUsed: number; enabled?: boolean; lastSwitchReason?: "rate-limit" | "initial" | "rotation"; rateLimitResetTimes?: RateLimitStateV3; coolingDownUntil?: number; cooldownReason?: CooldownReason; /** Per-account device fingerprint for rate limit mitigation */ fingerprint?: import("./fingerprint").Fingerprint; fingerprintHistory?: import("./fingerprint").FingerprintVersion[]; /** Set when Google asks the user to verify this account before requests can continue. */ verificationRequired?: boolean; verificationRequiredAt?: number; verificationRequiredReason?: string; verificationUrl?: string; /** Cached soft quota data */ cachedQuota?: Record; cachedQuotaUpdatedAt?: number; } export interface AccountStorageV3 { version: 3; accounts: AccountMetadataV3[]; activeIndex: number; activeIndexByFamily?: { claude?: number; gemini?: number; }; } export interface AccountStorageV4 { version: 4; accounts: AccountMetadataV3[]; activeIndex: number; activeIndexByFamily?: { claude?: number; gemini?: number; }; } type AnyAccountStorage = | AccountStorageV1 | AccountStorage | AccountStorageV3 | AccountStorageV4; /** * Gets the legacy Windows config directory (%APPDATA%\opencode). * Used for migration from older plugin versions. */ function getLegacyWindowsConfigDir(): string { return join( process.env.APPDATA || join(homedir(), "AppData", "Roaming"), "opencode", ); } /** * Gets the config directory path, with the following precedence: * 1. OPENCODE_CONFIG_DIR env var (if set) * 2. ~/.config/opencode (all platforms, including Windows) * * On Windows, also checks for legacy %APPDATA%\opencode path for migration. */ function getConfigDir(): string { // 1. Check for explicit override via env var if (process.env.OPENCODE_CONFIG_DIR) { return process.env.OPENCODE_CONFIG_DIR; } // 2. Use ~/.config/opencode on all platforms (including Windows) const xdgConfig = process.env.XDG_CONFIG_HOME || join(homedir(), ".config"); return join(xdgConfig, "opencode"); } /** * Migrates config from legacy Windows location to the new path. * Moves the file if legacy exists and new doesn't. * Returns true if migration was performed. */ function migrateLegacyWindowsConfig(): boolean { if (process.platform !== "win32") { return false; } const newPath = join(getConfigDir(), "antigravity-accounts.json"); const legacyPath = join( getLegacyWindowsConfigDir(), "antigravity-accounts.json", ); // Only migrate if legacy exists and new doesn't if (!existsSync(legacyPath) || existsSync(newPath)) { return false; } try { // Ensure new config directory exists const newConfigDir = getConfigDir(); mkdirSync(newConfigDir, { recursive: true }); // Try rename first (atomic, but fails across filesystems) try { renameSync(legacyPath, newPath); log.info("Migrated Windows config via rename", { from: legacyPath, to: newPath }); } catch { // Fallback: copy then delete (for cross-filesystem moves) copyFileSync(legacyPath, newPath); unlinkSync(legacyPath); log.info("Migrated Windows config via copy+delete", { from: legacyPath, to: newPath }); } return true; } catch (error) { log.warn("Failed to migrate legacy Windows config, will use legacy path", { legacyPath, newPath, error: String(error), }); return false; } } /** * Gets the storage path, migrating from legacy Windows location if needed. * On Windows, attempts to move legacy config to new path for alignment. */ function getStoragePathWithMigration(): string { const newPath = join(getConfigDir(), "antigravity-accounts.json"); // On Windows, attempt to migrate legacy config to new location if (process.platform === "win32") { migrateLegacyWindowsConfig(); // If migration failed and legacy still exists, fall back to it if (!existsSync(newPath)) { const legacyPath = join( getLegacyWindowsConfigDir(), "antigravity-accounts.json", ); if (existsSync(legacyPath)) { log.info("Using legacy Windows config path (migration failed)", { legacyPath, newPath, }); return legacyPath; } } } return newPath; } export function getStoragePath(): string { return getStoragePathWithMigration(); } /** * Gets the config directory path. Exported for use by other modules. */ export { getConfigDir }; const LOCK_OPTIONS = { stale: 10000, retries: { retries: 5, minTimeout: 100, maxTimeout: 1000, factor: 2, }, }; /** * Ensures the file has secure permissions (0600) on POSIX systems. * This is a best-effort operation and ignores errors on Windows/unsupported FS. */ async function ensureSecurePermissions(path: string): Promise { try { await fs.chmod(path, 0o600); } catch { // Ignore errors (e.g. Windows, file doesn't exist, FS doesn't support chmod) } } async function ensureFileExists(path: string): Promise { try { await fs.access(path); } catch { await fs.mkdir(dirname(path), { recursive: true }); await fs.writeFile( path, JSON.stringify({ version: 4, accounts: [], activeIndex: 0 }, null, 2), { encoding: "utf-8", mode: 0o600 }, ); } } async function withFileLock(path: string, fn: () => Promise): Promise { await ensureFileExists(path); let release: (() => Promise) | null = null; try { release = await lockfile.lock(path, LOCK_OPTIONS); return await fn(); } finally { if (release) { try { await release(); } catch (unlockError) { log.warn("Failed to release lock", { error: String(unlockError) }); } } } } function mergeAccountStorage( existing: AccountStorageV4, incoming: AccountStorageV4, ): AccountStorageV4 { const accountMap = new Map(); for (const acc of existing.accounts) { if (acc.refreshToken) { accountMap.set(acc.refreshToken, acc); } } for (const acc of incoming.accounts) { if (acc.refreshToken) { const existingAcc = accountMap.get(acc.refreshToken); if (existingAcc) { accountMap.set(acc.refreshToken, { ...existingAcc, ...acc, // Preserve manually configured projectId/managedProjectId if not in incoming projectId: acc.projectId ?? existingAcc.projectId, managedProjectId: acc.managedProjectId ?? existingAcc.managedProjectId, rateLimitResetTimes: { ...existingAcc.rateLimitResetTimes, ...acc.rateLimitResetTimes, }, lastUsed: Math.max(existingAcc.lastUsed || 0, acc.lastUsed || 0), }); } else { accountMap.set(acc.refreshToken, acc); } } } return { version: 4, accounts: Array.from(accountMap.values()), activeIndex: incoming.activeIndex, activeIndexByFamily: incoming.activeIndexByFamily, }; } export function deduplicateAccountsByEmail< T extends { email?: string; lastUsed?: number; addedAt?: number }, >(accounts: T[]): T[] { const emailToNewestIndex = new Map(); const indicesToKeep = new Set(); // First pass: find the newest account for each email (by lastUsed, then addedAt) for (let i = 0; i < accounts.length; i++) { const acc = accounts[i]; if (!acc) continue; if (!acc.email) { // No email - keep this account (can't deduplicate without email) indicesToKeep.add(i); continue; } const existingIndex = emailToNewestIndex.get(acc.email); if (existingIndex === undefined) { emailToNewestIndex.set(acc.email, i); continue; } // Compare to find which is newer const existing = accounts[existingIndex]; if (!existing) { emailToNewestIndex.set(acc.email, i); continue; } // Prefer higher lastUsed, then higher addedAt // Compare fields separately to avoid integer overflow with large timestamps const currLastUsed = acc.lastUsed || 0; const existLastUsed = existing.lastUsed || 0; const currAddedAt = acc.addedAt || 0; const existAddedAt = existing.addedAt || 0; const isNewer = currLastUsed > existLastUsed || (currLastUsed === existLastUsed && currAddedAt > existAddedAt); if (isNewer) { emailToNewestIndex.set(acc.email, i); } } // Add all the newest email-based indices to the keep set for (const idx of emailToNewestIndex.values()) { indicesToKeep.add(idx); } // Build the deduplicated list, preserving original order for kept items const result: T[] = []; for (let i = 0; i < accounts.length; i++) { if (indicesToKeep.has(i)) { const acc = accounts[i]; if (acc) { result.push(acc); } } } return result; } function migrateV1ToV2(v1: AccountStorageV1): AccountStorage { return { version: 2, accounts: v1.accounts.map((acc) => { const rateLimitResetTimes: RateLimitState = {}; if ( acc.isRateLimited && acc.rateLimitResetTime && acc.rateLimitResetTime > Date.now() ) { rateLimitResetTimes.claude = acc.rateLimitResetTime; rateLimitResetTimes.gemini = acc.rateLimitResetTime; } return { email: acc.email, refreshToken: acc.refreshToken, projectId: acc.projectId, managedProjectId: acc.managedProjectId, addedAt: acc.addedAt, lastUsed: acc.lastUsed, lastSwitchReason: acc.lastSwitchReason, rateLimitResetTimes: Object.keys(rateLimitResetTimes).length > 0 ? rateLimitResetTimes : undefined, }; }), activeIndex: v1.activeIndex, }; } export function migrateV2ToV3(v2: AccountStorage): AccountStorageV3 { return { version: 3, accounts: v2.accounts.map((acc) => { const rateLimitResetTimes: RateLimitStateV3 = {}; if ( acc.rateLimitResetTimes?.claude && acc.rateLimitResetTimes.claude > Date.now() ) { rateLimitResetTimes.claude = acc.rateLimitResetTimes.claude; } if ( acc.rateLimitResetTimes?.gemini && acc.rateLimitResetTimes.gemini > Date.now() ) { rateLimitResetTimes["gemini-antigravity"] = acc.rateLimitResetTimes.gemini; } return { email: acc.email, refreshToken: acc.refreshToken, projectId: acc.projectId, managedProjectId: acc.managedProjectId, addedAt: acc.addedAt, lastUsed: acc.lastUsed, lastSwitchReason: acc.lastSwitchReason, rateLimitResetTimes: Object.keys(rateLimitResetTimes).length > 0 ? rateLimitResetTimes : undefined, }; }), activeIndex: v2.activeIndex, }; } export function migrateV3ToV4(v3: AccountStorageV3): AccountStorageV4 { return { version: 4, accounts: v3.accounts.map((acc) => ({ ...acc, fingerprint: undefined, fingerprintHistory: undefined, })), activeIndex: v3.activeIndex, activeIndexByFamily: v3.activeIndexByFamily, }; } export async function loadAccounts(): Promise { try { const path = getStoragePath(); // Ensure permissions are correct on load (fixes existing files) await ensureSecurePermissions(path); const content = await fs.readFile(path, "utf-8"); const data = JSON.parse(content) as AnyAccountStorage; if (!Array.isArray(data.accounts)) { log.warn("Invalid storage format, ignoring"); return null; } let storage: AccountStorageV4; if (data.version === 1) { log.info("Migrating account storage from v1 to v4"); const v2 = migrateV1ToV2(data); const v3 = migrateV2ToV3(v2); storage = migrateV3ToV4(v3); try { await saveAccounts(storage); log.info("Migration to v4 complete"); } catch (saveError) { log.warn("Failed to persist migrated storage", { error: String(saveError), }); } } else if (data.version === 2) { log.info("Migrating account storage from v2 to v4"); const v3 = migrateV2ToV3(data); storage = migrateV3ToV4(v3); try { await saveAccounts(storage); log.info("Migration to v4 complete"); } catch (saveError) { log.warn("Failed to persist migrated storage", { error: String(saveError), }); } } else if (data.version === 3) { log.info("Migrating account storage from v3 to v4"); storage = migrateV3ToV4(data); try { await saveAccounts(storage); log.info("Migration to v4 complete"); } catch (saveError) { log.warn("Failed to persist migrated storage", { error: String(saveError), }); } } else if (data.version === 4) { storage = data; } else { log.warn("Unknown storage version, ignoring", { version: (data as { version?: unknown }).version, }); return null; } // Validate accounts have required fields const validAccounts = storage.accounts.filter( (a): a is AccountMetadataV3 => { return ( !!a && typeof a === "object" && typeof (a as AccountMetadataV3).refreshToken === "string" ); }, ); // Deduplicate accounts by email (keeps newest entry for each email) const deduplicatedAccounts = deduplicateAccountsByEmail(validAccounts); // Clamp activeIndex to valid range after deduplication let activeIndex = typeof storage.activeIndex === "number" && Number.isFinite(storage.activeIndex) ? storage.activeIndex : 0; if (deduplicatedAccounts.length > 0) { activeIndex = Math.min(activeIndex, deduplicatedAccounts.length - 1); activeIndex = Math.max(activeIndex, 0); } else { activeIndex = 0; } return { version: 4, accounts: deduplicatedAccounts, activeIndex, activeIndexByFamily: storage.activeIndexByFamily, }; } catch (error) { const code = (error as NodeJS.ErrnoException).code; if (code === "ENOENT") { return null; } log.error("Failed to load account storage", { error: String(error) }); return null; } } export async function saveAccounts(storage: AccountStorageV4): Promise { const path = getStoragePath(); const configDir = dirname(path); await fs.mkdir(configDir, { recursive: true }); await ensureGitignore(configDir); await withFileLock(path, async () => { const existing = await loadAccountsUnsafe(); const merged = existing ? mergeAccountStorage(existing, storage) : storage; const tempPath = `${path}.${randomBytes(6).toString("hex")}.tmp`; const content = JSON.stringify(merged, null, 2); try { await fs.writeFile(tempPath, content, { encoding: "utf-8", mode: 0o600 }); await fs.rename(tempPath, path); } catch (error) { // Clean up temp file on failure to prevent accumulation try { await fs.unlink(tempPath); } catch { // Ignore cleanup errors (file may not exist) } throw error; } }); } /** * Save accounts storage by replacing the entire file (no merge). * Use this for destructive operations like delete where we need to * remove accounts that would otherwise be merged back from existing storage. */ export async function saveAccountsReplace(storage: AccountStorageV4): Promise { const path = getStoragePath(); const configDir = dirname(path); await fs.mkdir(configDir, { recursive: true }); await ensureGitignore(configDir); await withFileLock(path, async () => { const tempPath = `${path}.${randomBytes(6).toString("hex")}.tmp`; const content = JSON.stringify(storage, null, 2); try { await fs.writeFile(tempPath, content, { encoding: "utf-8", mode: 0o600 }); await fs.rename(tempPath, path); } catch (error) { try { await fs.unlink(tempPath); } catch { // Ignore cleanup errors } throw error; } }); } async function loadAccountsUnsafe(): Promise { try { const path = getStoragePath(); // Ensure permissions are correct on load (fixes existing files) await ensureSecurePermissions(path); const content = await fs.readFile(path, "utf-8"); const parsed = JSON.parse(content); if (parsed.version === 1) { return migrateV3ToV4(migrateV2ToV3(migrateV1ToV2(parsed))); } if (parsed.version === 2) { return migrateV3ToV4(migrateV2ToV3(parsed)); } if (parsed.version === 3) { return migrateV3ToV4(parsed); } return { ...parsed, accounts: deduplicateAccountsByEmail(parsed.accounts), }; } catch (error) { const code = (error as NodeJS.ErrnoException).code; if (code === "ENOENT") { return null; } return null; } } export async function clearAccounts(): Promise { try { const path = getStoragePath(); await fs.unlink(path); } catch (error) { const code = (error as NodeJS.ErrnoException).code; if (code !== "ENOENT") { log.error("Failed to clear account storage", { error: String(error) }); } } } ================================================ FILE: src/plugin/stores/signature-store.ts ================================================ import type { SignatureStore, SignedThinking, ThoughtBuffer } from '../core/streaming/types'; export function createSignatureStore(): SignatureStore { const store = new Map(); return { get: (key: string) => store.get(key), set: (key: string, value: SignedThinking) => { store.set(key, value); }, has: (key: string) => store.has(key), delete: (key: string) => { store.delete(key); }, }; } export function createThoughtBuffer(): ThoughtBuffer { const buffer = new Map(); return { get: (index: number) => buffer.get(index), set: (index: number, text: string) => { buffer.set(index, text); }, clear: () => buffer.clear(), }; } export const defaultSignatureStore = createSignatureStore(); ================================================ FILE: src/plugin/thinking-recovery.ts ================================================ /** * Thinking Recovery Module * * Minimal implementation for recovering from corrupted thinking state. * When Claude's conversation history gets corrupted (thinking blocks stripped/malformed), * this module provides a "last resort" recovery by closing the current turn and starting fresh. * * Philosophy: "Let it crash and start again" - Instead of trying to fix corrupted state, * we abandon the corrupted turn and let Claude generate fresh thinking. */ // ============================================================================ // TYPES // ============================================================================ /** * Conversation state for thinking mode analysis */ export interface ConversationState { /** True if we're in an incomplete tool use loop (ends with functionResponse) */ inToolLoop: boolean; /** Index of first model message in current turn */ turnStartIdx: number; /** Whether the TURN started with thinking */ turnHasThinking: boolean; /** Index of last model message */ lastModelIdx: number; /** Whether last model msg has thinking */ lastModelHasThinking: boolean; /** Whether last model msg has tool calls */ lastModelHasToolCalls: boolean; } // ============================================================================ // DETECTION HELPERS // ============================================================================ /** * Checks if a message part is a thinking/reasoning block. */ function isThinkingPart(part: any): boolean { if (!part || typeof part !== "object") return false; return ( part.thought === true || part.type === "thinking" || part.type === "redacted_thinking" ); } /** * Checks if a message part is a function response (tool result). */ function isFunctionResponsePart(part: any): boolean { return part && typeof part === "object" && "functionResponse" in part; } /** * Checks if a message part is a function call. */ function isFunctionCallPart(part: any): boolean { return part && typeof part === "object" && "functionCall" in part; } /** * Checks if a message is a tool result container (user role with functionResponse). */ function isToolResultMessage(msg: any): boolean { if (!msg || msg.role !== "user") return false; const parts = msg.parts || []; return parts.some(isFunctionResponsePart); } /** * Checks if a message contains thinking/reasoning content. */ function messageHasThinking(msg: any): boolean { if (!msg || typeof msg !== "object") return false; // Gemini format: parts array if (Array.isArray(msg.parts)) { return msg.parts.some(isThinkingPart); } // Anthropic format: content array if (Array.isArray(msg.content)) { return msg.content.some( (block: any) => block?.type === "thinking" || block?.type === "redacted_thinking", ); } return false; } /** * Checks if a message contains tool calls. */ function messageHasToolCalls(msg: any): boolean { if (!msg || typeof msg !== "object") return false; // Gemini format: parts array with functionCall if (Array.isArray(msg.parts)) { return msg.parts.some(isFunctionCallPart); } // Anthropic format: content array with tool_use if (Array.isArray(msg.content)) { return msg.content.some((block: any) => block?.type === "tool_use"); } return false; } // ============================================================================ // CONVERSATION STATE ANALYSIS // ============================================================================ /** * Analyzes conversation state to detect tool use loops and thinking mode issues. * * Key insight: A "turn" can span multiple assistant messages in a tool-use loop. * We need to find the TURN START (first assistant message after last real user message) * and check if THAT message had thinking, not just the last assistant message. */ export function analyzeConversationState(contents: any[]): ConversationState { const state: ConversationState = { inToolLoop: false, turnStartIdx: -1, turnHasThinking: false, lastModelIdx: -1, lastModelHasThinking: false, lastModelHasToolCalls: false, }; if (!Array.isArray(contents) || contents.length === 0) { return state; } // First pass: Find the last "real" user message (not a tool result) let lastRealUserIdx = -1; for (let i = 0; i < contents.length; i++) { const msg = contents[i]; if (msg?.role === "user" && !isToolResultMessage(msg)) { lastRealUserIdx = i; } } // Second pass: Analyze conversation and find turn boundaries for (let i = 0; i < contents.length; i++) { const msg = contents[i]; const role = msg?.role; if (role === "model" || role === "assistant") { const hasThinking = messageHasThinking(msg); const hasToolCalls = messageHasToolCalls(msg); // Track if this is the turn start if (i > lastRealUserIdx && state.turnStartIdx === -1) { state.turnStartIdx = i; state.turnHasThinking = hasThinking; } state.lastModelIdx = i; state.lastModelHasToolCalls = hasToolCalls; state.lastModelHasThinking = hasThinking; } } // Determine if we're in a tool loop // We're in a tool loop if the conversation ends with a tool result if (contents.length > 0) { const lastMsg = contents[contents.length - 1]; if (lastMsg?.role === "user" && isToolResultMessage(lastMsg)) { state.inToolLoop = true; } } return state; } // ============================================================================ // RECOVERY FUNCTIONS // ============================================================================ /** * Strips all thinking blocks from messages. * Used before injecting synthetic messages to avoid invalid thinking patterns. */ function stripAllThinkingBlocks(contents: any[]): any[] { return contents.map((content) => { if (!content || typeof content !== "object") return content; // Handle Gemini-style parts if (Array.isArray(content.parts)) { const filteredParts = content.parts.filter( (part: any) => !isThinkingPart(part), ); // Keep at least one part to avoid empty messages if (filteredParts.length === 0 && content.parts.length > 0) { return content; } return { ...content, parts: filteredParts }; } // Handle Anthropic-style content if (Array.isArray(content.content)) { const filteredContent = content.content.filter( (block: any) => block?.type !== "thinking" && block?.type !== "redacted_thinking", ); if (filteredContent.length === 0 && content.content.length > 0) { return content; } return { ...content, content: filteredContent }; } return content; }); } /** * Counts tool results at the end of the conversation. */ function countTrailingToolResults(contents: any[]): number { let count = 0; for (let i = contents.length - 1; i >= 0; i--) { const msg = contents[i]; if (msg?.role === "user") { const parts = msg.parts || []; const functionResponses = parts.filter(isFunctionResponsePart); if (functionResponses.length > 0) { count += functionResponses.length; } else { break; // Real user message, stop counting } } else if (msg?.role === "model" || msg?.role === "assistant") { break; // Stop at the model that made the tool calls } } return count; } /** * Closes an incomplete tool loop by injecting synthetic messages to start a new turn. * * This is the "let it crash and start again" recovery mechanism. * * When we detect: * - We're in a tool loop (conversation ends with functionResponse) * - The tool call was made WITHOUT thinking (thinking was stripped/corrupted) * - We NOW want to enable thinking * * Instead of trying to fix the corrupted state, we: * 1. Strip ALL thinking blocks (removes any corrupted ones) * 2. Add synthetic MODEL message to complete the non-thinking turn * 3. Add synthetic USER message to start a NEW turn * * This allows Claude to generate fresh thinking for the new turn. */ export function closeToolLoopForThinking(contents: any[]): any[] { // Strip any old/corrupted thinking first const strippedContents = stripAllThinkingBlocks(contents); // Count tool results from the end of the conversation const toolResultCount = countTrailingToolResults(strippedContents); // Build synthetic model message content based on tool count let syntheticModelContent: string; if (toolResultCount === 0) { syntheticModelContent = "[Processing previous context.]"; } else if (toolResultCount === 1) { syntheticModelContent = "[Tool execution completed.]"; } else { syntheticModelContent = `[${toolResultCount} tool executions completed.]`; } // Step 1: Inject synthetic MODEL message to complete the non-thinking turn const syntheticModel = { role: "model", parts: [{ text: syntheticModelContent }], }; // Step 2: Inject synthetic USER message to start a NEW turn const syntheticUser = { role: "user", parts: [{ text: "[Continue]" }], }; return [...strippedContents, syntheticModel, syntheticUser]; } /** * Checks if conversation state requires tool loop closure for thinking recovery. * * Returns true if: * - We're in a tool loop (state.inToolLoop) * - The turn didn't start with thinking (state.turnHasThinking === false) * * This is the trigger for the "let it crash and start again" recovery. */ export function needsThinkingRecovery(state: ConversationState): boolean { return state.inToolLoop && !state.turnHasThinking; } // ============================================================================ // COMPACTED THINKING TURN DETECTION (Ported from LLM-API-Key-Proxy) // ============================================================================ /** * Detects if a message looks like it was compacted from a thinking-enabled turn. * * This is a heuristic to distinguish between: * - "Never had thinking" (model didn't use thinking mode) * - "Thinking was stripped" (context compaction removed thinking blocks) * * Port of LLM-API-Key-Proxy's _looks_like_compacted_thinking_turn() * * Heuristics: * 1. Has functionCall parts (typical thinking flow produces tool calls) * 2. No thinking parts (thought: true) * 3. No text content before functionCall (thinking responses usually have text) * * @param msg - A single message from the conversation * @returns true if the message looks like thinking was stripped */ export function looksLikeCompactedThinkingTurn(msg: any): boolean { if (!msg || typeof msg !== "object") return false; const parts = msg.parts || []; if (parts.length === 0) return false; // Check if message has function calls const hasFunctionCall = parts.some( (p: any) => p && typeof p === "object" && p.functionCall, ); if (!hasFunctionCall) return false; // Check for thinking blocks const hasThinking = parts.some( (p: any) => p && typeof p === "object" && (p.thought === true || p.type === "thinking" || p.type === "redacted_thinking"), ); if (hasThinking) return false; // Check for text content (not thinking) const hasTextBeforeFunctionCall = parts.some((p: any, idx: number) => { if (!p || typeof p !== "object") return false; // Only check parts before the first functionCall const firstFuncIdx = parts.findIndex( (fp: any) => fp && typeof fp === "object" && fp.functionCall, ); if (idx >= firstFuncIdx) return false; // Check for non-thinking text return ( "text" in p && typeof p.text === "string" && p.text.trim().length > 0 && !p.thought ); }); // If we have functionCall but no text before it, likely compacted return !hasTextBeforeFunctionCall; } /** * Checks if any message in the current turn looks like it was compacted. * * @param contents - Full conversation contents * @param turnStartIdx - Index of the first model message in current turn * @returns true if any model message in the turn looks compacted */ export function hasPossibleCompactedThinking( contents: any[], turnStartIdx: number, ): boolean { if (!Array.isArray(contents) || turnStartIdx < 0) return false; for (let i = turnStartIdx; i < contents.length; i++) { const msg = contents[i]; if (msg?.role === "model" && looksLikeCompactedThinkingTurn(msg)) { return true; } } return false; } ================================================ FILE: src/plugin/token.test.ts ================================================ import { beforeEach, describe, expect, it, vi } from "vitest"; import { ANTIGRAVITY_PROVIDER_ID } from "../constants"; import { AntigravityTokenRefreshError, refreshAccessToken } from "./token"; import type { OAuthAuthDetails, PluginClient } from "./types"; const baseAuth: OAuthAuthDetails = { type: "oauth", refresh: "refresh-token|project-123", access: "old-access", expires: Date.now() - 1000, }; function createClient() { return { auth: { set: vi.fn(async () => {}), }, } as PluginClient & { auth: { set: ReturnType }; }; } describe("refreshAccessToken", () => { beforeEach(() => { vi.restoreAllMocks(); }); it("updates the caller when refresh token is unchanged", async () => { const client = createClient(); const fetchMock = vi.fn(async () => { return new Response( JSON.stringify({ access_token: "new-access", expires_in: 3600, }), { status: 200 }, ); }); global.fetch = fetchMock as unknown as typeof fetch; const result = await refreshAccessToken(baseAuth, client, ANTIGRAVITY_PROVIDER_ID); expect(result?.access).toBe("new-access"); expect(client.auth.set.mock.calls.length).toBe(0); }); it("handles Google refresh token rotation", async () => { const client = createClient(); const fetchMock = vi.fn(async () => { return new Response( JSON.stringify({ access_token: "next-access", expires_in: 3600, refresh_token: "rotated-token", }), { status: 200 }, ); }); global.fetch = fetchMock as unknown as typeof fetch; const result = await refreshAccessToken(baseAuth, client, ANTIGRAVITY_PROVIDER_ID); expect(result?.access).toBe("next-access"); expect(result?.refresh).toContain("rotated-token"); expect(client.auth.set.mock.calls.length).toBe(0); }); it("throws a typed error on invalid_grant", async () => { const client = createClient(); const fetchMock = vi.fn(async () => { return new Response( JSON.stringify({ error: "invalid_grant", error_description: "Refresh token revoked", }), { status: 400, statusText: "Bad Request" }, ); }); global.fetch = fetchMock as unknown as typeof fetch; await expect(refreshAccessToken(baseAuth, client, ANTIGRAVITY_PROVIDER_ID)).rejects.toMatchObject({ name: "AntigravityTokenRefreshError", code: "invalid_grant", }); }); }); ================================================ FILE: src/plugin/token.ts ================================================ import { ANTIGRAVITY_CLIENT_ID, ANTIGRAVITY_CLIENT_SECRET } from "../constants"; import { formatRefreshParts, parseRefreshParts, calculateTokenExpiry } from "./auth"; import { clearCachedAuth, storeCachedAuth } from "./cache"; import { createLogger } from "./logger"; import { invalidateProjectContextCache } from "./project"; import type { OAuthAuthDetails, PluginClient, RefreshParts } from "./types"; const log = createLogger("token"); interface OAuthErrorPayload { error?: | string | { code?: string; status?: string; message?: string; }; error_description?: string; } /** * Parses OAuth error payloads returned by Google token endpoints, tolerating varied shapes. */ function parseOAuthErrorPayload(text: string | undefined): { code?: string; description?: string } { if (!text) { return {}; } try { const payload = JSON.parse(text) as OAuthErrorPayload; if (!payload || typeof payload !== "object") { return { description: text }; } let code: string | undefined; if (typeof payload.error === "string") { code = payload.error; } else if (payload.error && typeof payload.error === "object") { code = payload.error.status ?? payload.error.code; if (!payload.error_description && payload.error.message) { return { code, description: payload.error.message }; } } const description = payload.error_description; if (description) { return { code, description }; } if (payload.error && typeof payload.error === "object" && payload.error.message) { return { code, description: payload.error.message }; } return { code }; } catch { return { description: text }; } } export class AntigravityTokenRefreshError extends Error { code?: string; description?: string; status: number; statusText: string; constructor(options: { message: string; code?: string; description?: string; status: number; statusText: string; }) { super(options.message); this.name = "AntigravityTokenRefreshError"; this.code = options.code; this.description = options.description; this.status = options.status; this.statusText = options.statusText; } } /** * Refreshes an Antigravity OAuth access token, updates persisted credentials, and handles revocation. */ export async function refreshAccessToken( auth: OAuthAuthDetails, client: PluginClient, providerId: string, ): Promise { const parts = parseRefreshParts(auth.refresh); if (!parts.refreshToken) { return undefined; } try { const startTime = Date.now(); const response = await fetch("https://oauth2.googleapis.com/token", { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded", }, body: new URLSearchParams({ grant_type: "refresh_token", refresh_token: parts.refreshToken, client_id: ANTIGRAVITY_CLIENT_ID, client_secret: ANTIGRAVITY_CLIENT_SECRET, }), }); if (!response.ok) { let errorText: string | undefined; try { errorText = await response.text(); } catch { errorText = undefined; } const { code, description } = parseOAuthErrorPayload(errorText); const details = [code, description ?? errorText].filter(Boolean).join(": "); const baseMessage = `Antigravity token refresh failed (${response.status} ${response.statusText})`; const message = details ? `${baseMessage} - ${details}` : baseMessage; log.warn("Token refresh failed", { status: response.status, code, details }); if (code === "invalid_grant") { log.warn("Google revoked the stored refresh token - reauthentication required"); invalidateProjectContextCache(auth.refresh); clearCachedAuth(auth.refresh); } throw new AntigravityTokenRefreshError({ message, code, description: description ?? errorText, status: response.status, statusText: response.statusText, }); } const payload = (await response.json()) as { access_token: string; expires_in: number; refresh_token?: string; }; const refreshedParts: RefreshParts = { refreshToken: payload.refresh_token ?? parts.refreshToken, projectId: parts.projectId, managedProjectId: parts.managedProjectId, }; const updatedAuth: OAuthAuthDetails = { ...auth, access: payload.access_token, expires: calculateTokenExpiry(startTime, payload.expires_in), refresh: formatRefreshParts(refreshedParts), }; storeCachedAuth(updatedAuth); invalidateProjectContextCache(auth.refresh); return updatedAuth; } catch (error) { if (error instanceof AntigravityTokenRefreshError) { throw error; } log.error("Unexpected token refresh error", { error: String(error) }); return undefined; } } ================================================ FILE: src/plugin/transform/claude.test.ts ================================================ import { describe, it, expect } from "vitest"; import { isClaudeModel, isClaudeThinkingModel, configureClaudeToolConfig, buildClaudeThinkingConfig, ensureClaudeMaxOutputTokens, appendClaudeThinkingHint, normalizeClaudeTools, applyClaudeTransforms, CLAUDE_THINKING_MAX_OUTPUT_TOKENS, CLAUDE_INTERLEAVED_THINKING_HINT, } from "./claude"; import type { RequestPayload } from "./types"; describe("isClaudeModel", () => { it("returns true for claude model names", () => { expect(isClaudeModel("claude-sonnet-4-5")).toBe(true); expect(isClaudeModel("claude-opus-4-5")).toBe(true); expect(isClaudeModel("claude-3-opus")).toBe(true); expect(isClaudeModel("claude-3-5-sonnet")).toBe(true); }); it("returns true for case-insensitive matches", () => { expect(isClaudeModel("CLAUDE-SONNET-4-5")).toBe(true); expect(isClaudeModel("Claude-Opus-4-5")).toBe(true); expect(isClaudeModel("cLaUdE-3-opus")).toBe(true); }); it("returns true for prefixed claude models", () => { expect(isClaudeModel("antigravity-claude-sonnet-4-5")).toBe(true); expect(isClaudeModel("google/claude-opus-4-5")).toBe(true); }); it("returns false for non-claude models", () => { expect(isClaudeModel("gemini-3-pro")).toBe(false); expect(isClaudeModel("gpt-4")).toBe(false); expect(isClaudeModel("llama-3")).toBe(false); expect(isClaudeModel("")).toBe(false); }); it("returns false for similar but non-claude names", () => { expect(isClaudeModel("claudia-model")).toBe(false); expect(isClaudeModel("clade-model")).toBe(false); }); }); describe("isClaudeThinkingModel", () => { it("returns true for claude thinking models", () => { expect(isClaudeThinkingModel("claude-sonnet-4-5-thinking")).toBe(true); expect(isClaudeThinkingModel("claude-opus-4-5-thinking")).toBe(true); expect(isClaudeThinkingModel("claude-sonnet-4-5-thinking-high")).toBe(true); expect(isClaudeThinkingModel("claude-opus-4-5-thinking-low")).toBe(true); }); it("returns true for case-insensitive matches", () => { expect(isClaudeThinkingModel("CLAUDE-SONNET-4-5-THINKING")).toBe(true); expect(isClaudeThinkingModel("Claude-Opus-4-5-Thinking")).toBe(true); }); it("returns true for prefixed thinking models", () => { expect(isClaudeThinkingModel("antigravity-claude-sonnet-4-5-thinking")).toBe(true); expect(isClaudeThinkingModel("google/claude-opus-4-5-thinking-high")).toBe(true); }); it("returns false for non-thinking claude models", () => { expect(isClaudeThinkingModel("claude-sonnet-4-5")).toBe(false); expect(isClaudeThinkingModel("claude-opus-4-5")).toBe(false); expect(isClaudeThinkingModel("claude-3-opus")).toBe(false); }); it("returns false for non-claude models", () => { expect(isClaudeThinkingModel("gemini-3-pro-thinking")).toBe(false); expect(isClaudeThinkingModel("gpt-4-thinking")).toBe(false); }); it("requires both claude and thinking keywords", () => { expect(isClaudeThinkingModel("thinking-model")).toBe(false); expect(isClaudeThinkingModel("claude-model")).toBe(false); }); }); describe("configureClaudeToolConfig", () => { it("creates toolConfig if not present", () => { const payload: RequestPayload = {}; configureClaudeToolConfig(payload); expect(payload.toolConfig).toBeDefined(); expect((payload.toolConfig as any).functionCallingConfig).toBeDefined(); expect((payload.toolConfig as any).functionCallingConfig.mode).toBe("VALIDATED"); }); it("adds functionCallingConfig to existing toolConfig", () => { const payload: RequestPayload = { toolConfig: { someOtherConfig: true }, }; configureClaudeToolConfig(payload); expect((payload.toolConfig as any).someOtherConfig).toBe(true); expect((payload.toolConfig as any).functionCallingConfig.mode).toBe("VALIDATED"); }); it("sets mode to VALIDATED on existing functionCallingConfig", () => { const payload: RequestPayload = { toolConfig: { functionCallingConfig: { existingKey: "value" }, }, }; configureClaudeToolConfig(payload); expect((payload.toolConfig as any).functionCallingConfig.existingKey).toBe("value"); expect((payload.toolConfig as any).functionCallingConfig.mode).toBe("VALIDATED"); }); it("overwrites existing mode", () => { const payload: RequestPayload = { toolConfig: { functionCallingConfig: { mode: "AUTO" }, }, }; configureClaudeToolConfig(payload); expect((payload.toolConfig as any).functionCallingConfig.mode).toBe("VALIDATED"); }); it("handles null toolConfig gracefully", () => { const payload: RequestPayload = { toolConfig: null }; configureClaudeToolConfig(payload); expect(payload.toolConfig).toBeDefined(); }); }); describe("buildClaudeThinkingConfig", () => { it("builds config with include_thoughts only", () => { const config = buildClaudeThinkingConfig(true); expect(config).toEqual({ include_thoughts: true }); }); it("builds config with include_thoughts false", () => { const config = buildClaudeThinkingConfig(false); expect(config).toEqual({ include_thoughts: false }); }); it("includes thinking_budget when provided and positive", () => { const config = buildClaudeThinkingConfig(true, 8192); expect(config).toEqual({ include_thoughts: true, thinking_budget: 8192, }); }); it("excludes thinking_budget when zero", () => { const config = buildClaudeThinkingConfig(true, 0); expect(config).toEqual({ include_thoughts: true }); }); it("excludes thinking_budget when negative", () => { const config = buildClaudeThinkingConfig(true, -100); expect(config).toEqual({ include_thoughts: true }); }); it("excludes thinking_budget when undefined", () => { const config = buildClaudeThinkingConfig(true, undefined); expect(config).toEqual({ include_thoughts: true }); }); it("handles various budget values", () => { expect(buildClaudeThinkingConfig(true, 8192)).toHaveProperty("thinking_budget", 8192); expect(buildClaudeThinkingConfig(true, 16384)).toHaveProperty("thinking_budget", 16384); expect(buildClaudeThinkingConfig(true, 32768)).toHaveProperty("thinking_budget", 32768); }); }); describe("ensureClaudeMaxOutputTokens", () => { it("sets maxOutputTokens when not present", () => { const config: Record = {}; ensureClaudeMaxOutputTokens(config, 8192); expect(config.maxOutputTokens).toBe(CLAUDE_THINKING_MAX_OUTPUT_TOKENS); }); it("sets maxOutputTokens when current is less than budget", () => { const config: Record = { maxOutputTokens: 4096 }; ensureClaudeMaxOutputTokens(config, 8192); expect(config.maxOutputTokens).toBe(CLAUDE_THINKING_MAX_OUTPUT_TOKENS); }); it("sets maxOutputTokens when current equals budget", () => { const config: Record = { maxOutputTokens: 8192 }; ensureClaudeMaxOutputTokens(config, 8192); expect(config.maxOutputTokens).toBe(CLAUDE_THINKING_MAX_OUTPUT_TOKENS); }); it("does not change maxOutputTokens when current is greater than budget", () => { const config: Record = { maxOutputTokens: 100000 }; ensureClaudeMaxOutputTokens(config, 8192); expect(config.maxOutputTokens).toBe(100000); }); it("handles snake_case max_output_tokens", () => { const config: Record = { max_output_tokens: 4096 }; ensureClaudeMaxOutputTokens(config, 8192); expect(config.maxOutputTokens).toBe(CLAUDE_THINKING_MAX_OUTPUT_TOKENS); expect(config.max_output_tokens).toBeUndefined(); }); it("removes max_output_tokens when setting maxOutputTokens", () => { const config: Record = { max_output_tokens: 4096, maxOutputTokens: 4096, }; ensureClaudeMaxOutputTokens(config, 8192); expect(config.maxOutputTokens).toBe(CLAUDE_THINKING_MAX_OUTPUT_TOKENS); expect(config.max_output_tokens).toBeUndefined(); }); it("prefers maxOutputTokens over max_output_tokens for comparison", () => { const config: Record = { maxOutputTokens: 100000, max_output_tokens: 4096, }; ensureClaudeMaxOutputTokens(config, 8192); expect(config.maxOutputTokens).toBe(100000); }); }); describe("appendClaudeThinkingHint", () => { describe("with string systemInstruction", () => { it("appends hint to existing string instruction", () => { const payload: RequestPayload = { systemInstruction: "You are a helpful assistant.", }; appendClaudeThinkingHint(payload); expect(payload.systemInstruction).toBe( `You are a helpful assistant.\n\n${CLAUDE_INTERLEAVED_THINKING_HINT}` ); }); it("uses hint alone when existing instruction is empty", () => { const payload: RequestPayload = { systemInstruction: "", }; appendClaudeThinkingHint(payload); expect(payload.systemInstruction).toBe(CLAUDE_INTERLEAVED_THINKING_HINT); }); it("uses hint alone when existing instruction is whitespace", () => { const payload: RequestPayload = { systemInstruction: " ", }; appendClaudeThinkingHint(payload); expect(payload.systemInstruction).toBe(CLAUDE_INTERLEAVED_THINKING_HINT); }); it("accepts custom hint", () => { const payload: RequestPayload = { systemInstruction: "Base instruction.", }; appendClaudeThinkingHint(payload, "Custom hint."); expect(payload.systemInstruction).toBe("Base instruction.\n\nCustom hint."); }); }); describe("with object systemInstruction (parts array)", () => { it("appends hint to last text part", () => { const payload: RequestPayload = { systemInstruction: { parts: [{ text: "First part." }, { text: "Last part." }], }, }; appendClaudeThinkingHint(payload); const sys = payload.systemInstruction as any; expect(sys.parts[0].text).toBe("First part."); expect(sys.parts[1].text).toBe(`Last part.\n\n${CLAUDE_INTERLEAVED_THINKING_HINT}`); }); it("appends hint to single text part", () => { const payload: RequestPayload = { systemInstruction: { parts: [{ text: "Only part." }], }, }; appendClaudeThinkingHint(payload); const sys = payload.systemInstruction as any; expect(sys.parts[0].text).toBe(`Only part.\n\n${CLAUDE_INTERLEAVED_THINKING_HINT}`); }); it("creates new text part when no text parts exist", () => { const payload: RequestPayload = { systemInstruction: { parts: [{ image: "base64data" }], }, }; appendClaudeThinkingHint(payload); const sys = payload.systemInstruction as any; expect(sys.parts).toHaveLength(2); expect(sys.parts[1].text).toBe(CLAUDE_INTERLEAVED_THINKING_HINT); }); it("creates parts array when not present", () => { const payload: RequestPayload = { systemInstruction: { role: "system" }, }; appendClaudeThinkingHint(payload); const sys = payload.systemInstruction as any; expect(sys.parts).toEqual([{ text: CLAUDE_INTERLEAVED_THINKING_HINT }]); }); }); describe("with no systemInstruction", () => { it("creates systemInstruction when contents array exists", () => { const payload: RequestPayload = { contents: [{ role: "user", parts: [{ text: "Hello" }] }], }; appendClaudeThinkingHint(payload); expect(payload.systemInstruction).toEqual({ parts: [{ text: CLAUDE_INTERLEAVED_THINKING_HINT }], }); }); it("does not create systemInstruction when no contents", () => { const payload: RequestPayload = {}; appendClaudeThinkingHint(payload); expect(payload.systemInstruction).toBeUndefined(); }); }); }); describe("normalizeClaudeTools", () => { const identityClean = (schema: unknown) => schema as Record; const realClean = (schema: unknown): Record => { if (!schema || typeof schema !== "object") return {}; const cleaned = { ...schema as Record }; delete cleaned.$schema; delete cleaned.$id; return cleaned; }; it("returns empty result when no tools", () => { const payload: RequestPayload = {}; const result = normalizeClaudeTools(payload, identityClean); expect(result.toolDebugMissing).toBe(0); expect(result.toolDebugSummaries).toEqual([]); }); it("returns empty result when tools is not an array", () => { const payload: RequestPayload = { tools: "not an array" }; const result = normalizeClaudeTools(payload, identityClean); expect(result.toolDebugMissing).toBe(0); expect(result.toolDebugSummaries).toEqual([]); }); describe("functionDeclarations format", () => { it("normalizes tools with functionDeclarations array", () => { const payload: RequestPayload = { tools: [{ functionDeclarations: [{ name: "get_weather", description: "Get weather for a location", parameters: { type: "object", properties: { location: { type: "string" }, }, required: ["location"], }, }], }], }; const result = normalizeClaudeTools(payload, identityClean); expect(result.toolDebugMissing).toBe(0); expect(result.toolDebugSummaries).toContain("decl=get_weather,src=functionDeclarations,hasSchema=y"); const tools = payload.tools as any[]; expect(tools).toHaveLength(1); expect(tools[0].functionDeclarations).toHaveLength(1); expect(tools[0].functionDeclarations[0].name).toBe("get_weather"); }); it("handles multiple functionDeclarations", () => { const payload: RequestPayload = { tools: [{ functionDeclarations: [ { name: "tool1", description: "First tool" }, { name: "tool2", description: "Second tool" }, ], }], }; normalizeClaudeTools(payload, identityClean); const tools = payload.tools as any[]; expect(tools[0].functionDeclarations).toHaveLength(2); }); }); describe("function/custom format", () => { it("normalizes OpenAI-style function tools", () => { const payload: RequestPayload = { tools: [{ type: "function", function: { name: "search", description: "Search the web", parameters: { type: "object", properties: { query: { type: "string" }, }, }, }, }], }; const result = normalizeClaudeTools(payload, identityClean); expect(result.toolDebugSummaries).toContain("decl=search,src=function/custom,hasSchema=y"); const tools = payload.tools as any[]; expect(tools[0].functionDeclarations[0].name).toBe("search"); }); it("normalizes custom-style tools", () => { const payload: RequestPayload = { tools: [{ custom: { name: "custom_tool", description: "A custom tool", input_schema: { type: "object", properties: { arg: { type: "string" } }, }, }, }], }; const result = normalizeClaudeTools(payload, identityClean); expect(result.toolDebugSummaries).toContain("decl=custom_tool,src=function/custom,hasSchema=y"); }); it("normalizes tools with top-level name/parameters", () => { const payload: RequestPayload = { tools: [{ name: "direct_tool", description: "Direct definition", parameters: { type: "object", properties: { value: { type: "number" } }, }, }], }; normalizeClaudeTools(payload, identityClean); const tools = payload.tools as any[]; expect(tools[0].functionDeclarations[0].name).toBe("direct_tool"); }); }); describe("schema normalization", () => { it("adds placeholder when schema is missing", () => { const payload: RequestPayload = { tools: [{ function: { name: "no_schema_tool", description: "Tool without schema", }, }], }; const result = normalizeClaudeTools(payload, identityClean); expect(result.toolDebugMissing).toBe(1); const tools = payload.tools as any[]; const params = tools[0].functionDeclarations[0].parameters; expect(params.type).toBe("object"); expect(params.properties._placeholder).toBeDefined(); expect(params.required).toContain("_placeholder"); }); it("adds placeholder when schema has no properties", () => { const payload: RequestPayload = { tools: [{ function: { name: "empty_schema_tool", parameters: { type: "object" }, }, }], }; normalizeClaudeTools(payload, identityClean); const tools = payload.tools as any[]; const params = tools[0].functionDeclarations[0].parameters; expect(params.properties._placeholder).toBeDefined(); }); it("preserves existing properties", () => { const payload: RequestPayload = { tools: [{ function: { name: "has_props_tool", parameters: { type: "object", properties: { existingProp: { type: "string" }, }, }, }, }], }; normalizeClaudeTools(payload, identityClean); const tools = payload.tools as any[]; const params = tools[0].functionDeclarations[0].parameters; expect(params.properties.existingProp).toBeDefined(); expect(params.properties._placeholder).toBeUndefined(); }); it("cleans schema using provided function", () => { const payload: RequestPayload = { tools: [{ function: { name: "needs_cleaning", parameters: { $schema: "http://json-schema.org/draft-07/schema#", type: "object", properties: { arg: { type: "string" } }, }, }, }], }; normalizeClaudeTools(payload, realClean); const tools = payload.tools as any[]; const params = tools[0].functionDeclarations[0].parameters; expect(params.$schema).toBeUndefined(); expect(params.properties.arg).toBeDefined(); }); }); describe("tool name sanitization", () => { it("removes special characters from tool names", () => { const payload: RequestPayload = { tools: [{ function: { name: "tool@with#special$chars!", parameters: { type: "object", properties: { x: { type: "string" } } }, }, }], }; normalizeClaudeTools(payload, identityClean); const tools = payload.tools as any[]; expect(tools[0].functionDeclarations[0].name).toBe("tool_with_special_chars_"); }); it("truncates long tool names to 64 characters", () => { const longName = "a".repeat(100); const payload: RequestPayload = { tools: [{ function: { name: longName, parameters: { type: "object", properties: { x: { type: "string" } } }, }, }], }; normalizeClaudeTools(payload, identityClean); const tools = payload.tools as any[]; expect(tools[0].functionDeclarations[0].name).toHaveLength(64); }); it("generates name when missing", () => { const payload: RequestPayload = { tools: [{ function: { description: "Nameless tool", parameters: { type: "object", properties: { x: { type: "string" } } }, }, }], }; normalizeClaudeTools(payload, identityClean); const tools = payload.tools as any[]; expect(tools[0].functionDeclarations[0].name).toBe("tool-0"); }); }); describe("passthrough tools", () => { it("preserves non-function tools like codeExecution", () => { const payload: RequestPayload = { tools: [ { codeExecution: {} }, { function: { name: "regular_tool", parameters: { type: "object", properties: { x: { type: "string" } } }, }, }, ], }; normalizeClaudeTools(payload, identityClean); const tools = payload.tools as any[]; expect(tools).toHaveLength(2); expect(tools[0].functionDeclarations).toBeDefined(); expect(tools[1].codeExecution).toBeDefined(); }); }); }); describe("applyClaudeTransforms", () => { const mockCleanJSONSchema = (schema: unknown) => schema as Record; it("applies tool config for all Claude models", () => { const payload: RequestPayload = {}; applyClaudeTransforms(payload, { model: "claude-sonnet-4-6", cleanJSONSchema: mockCleanJSONSchema, }); expect((payload.toolConfig as any)?.functionCallingConfig?.mode).toBe("VALIDATED"); }); it("applies thinking config for thinking models", () => { const payload: RequestPayload = {}; applyClaudeTransforms(payload, { model: "claude-opus-4-6-thinking", normalizedThinking: { includeThoughts: true, thinkingBudget: 8192 }, cleanJSONSchema: mockCleanJSONSchema, }); const genConfig = payload.generationConfig as any; expect(genConfig.thinkingConfig.include_thoughts).toBe(true); expect(genConfig.thinkingConfig.thinking_budget).toBe(8192); }); it("uses tierThinkingBudget over normalizedThinking.thinkingBudget", () => { const payload: RequestPayload = {}; applyClaudeTransforms(payload, { model: "claude-opus-4-6-thinking", tierThinkingBudget: 32768, normalizedThinking: { includeThoughts: true, thinkingBudget: 8192 }, cleanJSONSchema: mockCleanJSONSchema, }); const genConfig = payload.generationConfig as any; expect(genConfig.thinkingConfig.thinking_budget).toBe(32768); }); it("ensures maxOutputTokens for thinking models with budget", () => { const payload: RequestPayload = { generationConfig: { maxOutputTokens: 4096 }, }; applyClaudeTransforms(payload, { model: "claude-opus-4-6-thinking", normalizedThinking: { includeThoughts: true, thinkingBudget: 8192 }, cleanJSONSchema: mockCleanJSONSchema, }); const genConfig = payload.generationConfig as any; expect(genConfig.maxOutputTokens).toBe(CLAUDE_THINKING_MAX_OUTPUT_TOKENS); }); it("does not apply thinking config for non-thinking models", () => { const payload: RequestPayload = {}; applyClaudeTransforms(payload, { model: "claude-sonnet-4-6", normalizedThinking: { includeThoughts: true, thinkingBudget: 8192 }, cleanJSONSchema: mockCleanJSONSchema, }); const genConfig = payload.generationConfig as any; expect(genConfig?.thinkingConfig).toBeUndefined(); }); it("appends thinking hint for thinking models with tools", () => { const payload: RequestPayload = { systemInstruction: "You are helpful.", tools: [{ function: { name: "test", parameters: { type: "object", properties: { x: { type: "string" } } } } }], }; applyClaudeTransforms(payload, { model: "claude-opus-4-6-thinking", cleanJSONSchema: mockCleanJSONSchema, }); expect((payload.systemInstruction as string)).toContain(CLAUDE_INTERLEAVED_THINKING_HINT); }); it("does not append thinking hint for thinking models without tools", () => { const payload: RequestPayload = { systemInstruction: "You are helpful.", }; applyClaudeTransforms(payload, { model: "claude-opus-4-6-thinking", cleanJSONSchema: mockCleanJSONSchema, }); expect((payload.systemInstruction as string)).not.toContain(CLAUDE_INTERLEAVED_THINKING_HINT); }); it("does not append thinking hint for non-thinking models with tools", () => { const payload: RequestPayload = { systemInstruction: "You are helpful.", tools: [{ function: { name: "test", parameters: { type: "object", properties: { x: { type: "string" } } } } }], }; applyClaudeTransforms(payload, { model: "claude-sonnet-4-6", cleanJSONSchema: mockCleanJSONSchema, }); expect((payload.systemInstruction as string)).not.toContain(CLAUDE_INTERLEAVED_THINKING_HINT); }); it("normalizes tools and returns debug info", () => { const payload: RequestPayload = { tools: [{ function: { name: "my_tool" } }], }; const result = applyClaudeTransforms(payload, { model: "claude-sonnet-4-6", cleanJSONSchema: mockCleanJSONSchema, }); expect(result.toolDebugMissing).toBe(1); expect(result.toolDebugSummaries).toContain("decl=my_tool,src=function/custom,hasSchema=n"); }); it("converts stop_sequences in generationConfig", () => { const payload: RequestPayload = { generationConfig: { stop_sequences: ["END"] }, }; applyClaudeTransforms(payload, { model: "claude-sonnet-4-6", cleanJSONSchema: mockCleanJSONSchema, }); const genConfig = payload.generationConfig as any; expect(genConfig.stopSequences).toEqual(["END"]); expect(genConfig.stop_sequences).toBeUndefined(); }); }); describe("constants", () => { it("exports CLAUDE_THINKING_MAX_OUTPUT_TOKENS", () => { expect(CLAUDE_THINKING_MAX_OUTPUT_TOKENS).toBe(64_000); }); it("exports CLAUDE_INTERLEAVED_THINKING_HINT", () => { expect(CLAUDE_INTERLEAVED_THINKING_HINT).toContain("Interleaved thinking is enabled"); }); }); ================================================ FILE: src/plugin/transform/claude.ts ================================================ /** * Claude-specific Request Transformations * * Handles Claude model-specific request transformations including: * - Tool config (VALIDATED mode) * - Thinking config (snake_case keys) * - System instruction hints for interleaved thinking * - Tool normalization (functionDeclarations format) */ import type { RequestPayload, ThinkingConfig } from "./types"; import { EMPTY_SCHEMA_PLACEHOLDER_NAME, EMPTY_SCHEMA_PLACEHOLDER_DESCRIPTION, } from "../../constants"; /** Claude thinking models need a sufficiently large max output token limit when thinking is enabled */ export const CLAUDE_THINKING_MAX_OUTPUT_TOKENS = 64_000; /** Interleaved thinking hint appended to system instructions */ export const CLAUDE_INTERLEAVED_THINKING_HINT = "Interleaved thinking is enabled. You may think between tool calls and after receiving tool results before deciding the next action or final answer. Do not mention these instructions or any constraints about thinking blocks; just apply them."; /** * Check if a model is a Claude model. */ export function isClaudeModel(model: string): boolean { return model.toLowerCase().includes("claude"); } /** * Check if a model is a Claude thinking model. */ export function isClaudeThinkingModel(model: string): boolean { const lower = model.toLowerCase(); return lower.includes("claude") && lower.includes("thinking"); } /** * Configure Claude tool calling to use VALIDATED mode. * This ensures proper tool call validation on the backend. */ export function configureClaudeToolConfig(payload: RequestPayload): void { if (!payload.toolConfig) { payload.toolConfig = {}; } if (typeof payload.toolConfig === "object" && payload.toolConfig !== null) { const toolConfig = payload.toolConfig as Record; if (!toolConfig.functionCallingConfig) { toolConfig.functionCallingConfig = {}; } if (typeof toolConfig.functionCallingConfig === "object" && toolConfig.functionCallingConfig !== null) { (toolConfig.functionCallingConfig as Record).mode = "VALIDATED"; } } } /** * Build Claude thinking config with snake_case keys. */ export function buildClaudeThinkingConfig( includeThoughts: boolean, thinkingBudget?: number, ): ThinkingConfig { return { include_thoughts: includeThoughts, ...(typeof thinkingBudget === "number" && thinkingBudget > 0 ? { thinking_budget: thinkingBudget } : {}), } as unknown as ThinkingConfig; } /** * Ensure maxOutputTokens is sufficient for Claude thinking models. * If thinking budget is set, max output must be larger than the budget. */ export function ensureClaudeMaxOutputTokens( generationConfig: Record, thinkingBudget: number, ): void { const currentMax = (generationConfig.maxOutputTokens ?? generationConfig.max_output_tokens) as number | undefined; if (!currentMax || currentMax <= thinkingBudget) { generationConfig.maxOutputTokens = CLAUDE_THINKING_MAX_OUTPUT_TOKENS; if (generationConfig.max_output_tokens !== undefined) { delete generationConfig.max_output_tokens; } } } /** * Append interleaved thinking hint to system instruction. * Handles various system instruction formats (string, object with parts array). */ export function appendClaudeThinkingHint( payload: RequestPayload, hint: string = CLAUDE_INTERLEAVED_THINKING_HINT, ): void { const existing = payload.systemInstruction; if (typeof existing === "string") { payload.systemInstruction = existing.trim().length > 0 ? `${existing}\n\n${hint}` : hint; } else if (existing && typeof existing === "object") { const sys = existing as Record; const partsValue = sys.parts; if (Array.isArray(partsValue)) { const parts = partsValue as unknown[]; let appended = false; // Find the last text part and append to it for (let i = parts.length - 1; i >= 0; i--) { const part = parts[i]; if (part && typeof part === "object") { const partRecord = part as Record; const text = partRecord.text; if (typeof text === "string") { partRecord.text = `${text}\n\n${hint}`; appended = true; break; } } } if (!appended) { parts.push({ text: hint }); } } else { sys.parts = [{ text: hint }]; } payload.systemInstruction = sys; } else if (Array.isArray(payload.contents)) { // No existing system instruction, create one payload.systemInstruction = { parts: [{ text: hint }] }; } } /** * Normalize tools for Claude models. * Converts various tool formats to functionDeclarations format. * * @returns Debug info about tool normalization */ export function normalizeClaudeTools( payload: RequestPayload, cleanJSONSchema: (schema: unknown) => Record, ): { toolDebugMissing: number; toolDebugSummaries: string[] } { let toolDebugMissing = 0; const toolDebugSummaries: string[] = []; if (!Array.isArray(payload.tools)) { return { toolDebugMissing, toolDebugSummaries }; } const functionDeclarations: unknown[] = []; const passthroughTools: unknown[] = []; const normalizeSchema = (schema: unknown): Record => { const createPlaceholderSchema = (base: Record = {}): Record => ({ ...base, type: "object", properties: { [EMPTY_SCHEMA_PLACEHOLDER_NAME]: { type: "boolean", description: EMPTY_SCHEMA_PLACEHOLDER_DESCRIPTION, }, }, required: [EMPTY_SCHEMA_PLACEHOLDER_NAME], }); if (!schema || typeof schema !== "object" || Array.isArray(schema)) { toolDebugMissing += 1; return createPlaceholderSchema(); } const cleaned = cleanJSONSchema(schema); if (!cleaned || typeof cleaned !== "object" || Array.isArray(cleaned)) { toolDebugMissing += 1; return createPlaceholderSchema(); } // Claude VALIDATED mode requires tool parameters to be an object schema // with at least one property. const hasProperties = cleaned.properties && typeof cleaned.properties === "object" && Object.keys(cleaned.properties as Record).length > 0; cleaned.type = "object"; if (!hasProperties) { cleaned.properties = { _placeholder: { type: "boolean", description: "Placeholder. Always pass true.", }, }; cleaned.required = Array.isArray(cleaned.required) ? Array.from(new Set([...(cleaned.required as string[]), "_placeholder"])) : ["_placeholder"]; } return cleaned; }; (payload.tools as unknown[]).forEach((tool: unknown) => { const t = tool as Record; const pushDeclaration = (decl: Record | undefined, source: string): void => { const schema = decl?.parameters || decl?.parametersJsonSchema || decl?.input_schema || decl?.inputSchema || t.parameters || t.parametersJsonSchema || t.input_schema || t.inputSchema || (t.function as Record | undefined)?.parameters || (t.function as Record | undefined)?.parametersJsonSchema || (t.function as Record | undefined)?.input_schema || (t.function as Record | undefined)?.inputSchema || (t.custom as Record | undefined)?.parameters || (t.custom as Record | undefined)?.parametersJsonSchema || (t.custom as Record | undefined)?.input_schema; let name = decl?.name || t.name || (t.function as Record | undefined)?.name || (t.custom as Record | undefined)?.name || `tool-${functionDeclarations.length}`; // Sanitize tool name: must be alphanumeric with underscores, no special chars name = String(name).replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 64); const description = decl?.description || t.description || (t.function as Record | undefined)?.description || (t.custom as Record | undefined)?.description || ""; functionDeclarations.push({ name, description: String(description || ""), parameters: normalizeSchema(schema), }); toolDebugSummaries.push( `decl=${name},src=${source},hasSchema=${schema ? "y" : "n"}`, ); }; // Check for functionDeclarations array first if (Array.isArray(t.functionDeclarations) && (t.functionDeclarations as unknown[]).length > 0) { (t.functionDeclarations as Record[]).forEach((decl) => pushDeclaration(decl, "functionDeclarations") ); return; } // Fall back to function/custom style definitions if (t.function || t.custom || t.parameters || t.input_schema || t.inputSchema) { pushDeclaration( (t.function as Record | undefined) ?? (t.custom as Record | undefined) ?? t, "function/custom" ); return; } // Preserve any non-function tool entries (e.g., codeExecution) untouched passthroughTools.push(tool); }); const finalTools: unknown[] = []; if (functionDeclarations.length > 0) { finalTools.push({ functionDeclarations }); } payload.tools = finalTools.concat(passthroughTools); return { toolDebugMissing, toolDebugSummaries }; } /** * Convert snake_case stop_sequences to camelCase stopSequences. */ export function convertStopSequences( generationConfig: Record, ): void { if (Array.isArray(generationConfig.stop_sequences)) { generationConfig.stopSequences = generationConfig.stop_sequences; delete generationConfig.stop_sequences; } } /** * Apply all Claude-specific transformations to a request payload. */ export interface ClaudeTransformOptions { /** The effective model name (resolved) */ model: string; /** Tier-based thinking budget (from model suffix) */ tierThinkingBudget?: number; /** Normalized thinking config from user settings */ normalizedThinking?: { includeThoughts?: boolean; thinkingBudget?: number }; /** Function to clean JSON schema for Antigravity */ cleanJSONSchema: (schema: unknown) => Record; } export interface ClaudeTransformResult { toolDebugMissing: number; toolDebugSummaries: string[]; } /** * Apply all Claude-specific transformations. */ export function applyClaudeTransforms( payload: RequestPayload, options: ClaudeTransformOptions, ): ClaudeTransformResult { const { model, tierThinkingBudget, normalizedThinking, cleanJSONSchema } = options; const isThinking = isClaudeThinkingModel(model); // 1. Configure tool calling mode configureClaudeToolConfig(payload); if (payload.generationConfig) { convertStopSequences(payload.generationConfig as Record); } // 2. Apply thinking config if needed if (normalizedThinking) { const thinkingBudget = tierThinkingBudget ?? normalizedThinking.thinkingBudget; if (isThinking) { const thinkingConfig = buildClaudeThinkingConfig( normalizedThinking.includeThoughts ?? true, thinkingBudget, ); const generationConfig = (payload.generationConfig ?? {}) as Record; generationConfig.thinkingConfig = thinkingConfig; if (typeof thinkingBudget === "number" && thinkingBudget > 0) { ensureClaudeMaxOutputTokens(generationConfig, thinkingBudget); } payload.generationConfig = generationConfig; } } // 3. Append interleaved thinking hint for thinking models with tools if (isThinking && Array.isArray(payload.tools) && (payload.tools as unknown[]).length > 0) { appendClaudeThinkingHint(payload); } // 4. Normalize tools return normalizeClaudeTools(payload, cleanJSONSchema); } ================================================ FILE: src/plugin/transform/cross-model-sanitizer.test.ts ================================================ import { describe, it, expect } from "vitest"; import { getModelFamily, stripGeminiThinkingMetadata, stripClaudeThinkingFields, sanitizeCrossModelPayload, deepSanitizeCrossModelMetadata, sanitizeCrossModelPayloadInPlace, } from "./cross-model-sanitizer"; describe("cross-model-sanitizer", () => { describe("getModelFamily", () => { it("identifies Claude models", () => { expect(getModelFamily("claude-opus-4-6-thinking-medium")).toBe("claude"); expect(getModelFamily("claude-sonnet-4-6")).toBe("claude"); expect(getModelFamily("claude-opus-4-6-thinking-low")).toBe("claude"); }); it("identifies Gemini models", () => { expect(getModelFamily("gemini-3-pro-low")).toBe("gemini"); expect(getModelFamily("gemini-3-flash")).toBe("gemini"); expect(getModelFamily("gemini-2.5-pro")).toBe("gemini"); }); it("returns unknown for unrecognized models", () => { expect(getModelFamily("gpt-4")).toBe("unknown"); expect(getModelFamily("unknown-model")).toBe("unknown"); }); }); describe("stripGeminiThinkingMetadata", () => { it("removes top-level thoughtSignature", () => { const part = { thought: true, text: "thinking...", thoughtSignature: "EsgQCsUQAXLI2ny...", }; const result = stripGeminiThinkingMetadata(part); expect(result.part.thoughtSignature).toBeUndefined(); expect(result.stripped).toBe(1); expect(result.part.text).toBe("thinking..."); }); it("removes top-level thinkingMetadata", () => { const part = { text: "response", thinkingMetadata: { someData: true }, }; const result = stripGeminiThinkingMetadata(part); expect(result.part.thinkingMetadata).toBeUndefined(); expect(result.stripped).toBe(1); }); it("removes nested metadata.google.thoughtSignature", () => { const part = { functionCall: { name: "bash", args: { command: "df -h" } }, metadata: { google: { thoughtSignature: "EsgQCsUQAXLI2ny...", }, }, }; const result = stripGeminiThinkingMetadata(part); const metadata = result.part.metadata as Record | undefined; const google = metadata?.google as Record | undefined; expect(google?.thoughtSignature).toBeUndefined(); expect(result.stripped).toBe(1); }); it("preserves non-signature metadata when preserveNonSignature is true", () => { const part = { functionCall: { name: "bash" }, metadata: { google: { thoughtSignature: "sig123", groundingMetadata: "preserved", }, cache_control: { type: "ephemeral" }, }, }; const result = stripGeminiThinkingMetadata(part, true); const metadata = result.part.metadata as Record | undefined; const google = metadata?.google as Record | undefined; const cacheControl = metadata?.cache_control as Record | undefined; expect(google?.thoughtSignature).toBeUndefined(); expect(google?.groundingMetadata).toBe("preserved"); expect(cacheControl?.type).toBe("ephemeral"); }); it("cleans up empty google object", () => { const part = { text: "hello", metadata: { google: { thoughtSignature: "sig123", }, }, }; const result = stripGeminiThinkingMetadata(part, true); const metadata = result.part.metadata as Record | undefined; const google = metadata?.google as Record | undefined; expect(google).toBeUndefined(); }); it("cleans up empty metadata object", () => { const part = { text: "hello", metadata: { google: { thoughtSignature: "sig123", }, }, }; const result = stripGeminiThinkingMetadata(part, true); expect(result.part.metadata).toBeUndefined(); }); it("handles parts without metadata", () => { const part = { text: "Hello" }; const result = stripGeminiThinkingMetadata(part); expect(result.part).toEqual({ text: "Hello" }); expect(result.stripped).toBe(0); }); }); describe("stripClaudeThinkingFields", () => { it("removes signature from thinking blocks", () => { const part = { type: "thinking", thinking: "Analyzing...", signature: "claude-sig-abc123def456...", }; const result = stripClaudeThinkingFields(part); expect(result.part.signature).toBeUndefined(); expect(result.stripped).toBe(1); expect(result.part.thinking).toBe("Analyzing..."); }); it("removes signature from redacted_thinking blocks", () => { const part = { type: "redacted_thinking", data: "encrypted", signature: "a]".repeat(30), }; const result = stripClaudeThinkingFields(part); expect(result.part.signature).toBeUndefined(); expect(result.stripped).toBe(1); }); it("removes long signature from non-thinking parts", () => { const part = { type: "text", text: "hello", signature: "a".repeat(60), }; const result = stripClaudeThinkingFields(part); expect(result.part.signature).toBeUndefined(); expect(result.stripped).toBe(1); }); it("preserves short signature-like fields", () => { const part = { type: "text", text: "hello", signature: "short", }; const result = stripClaudeThinkingFields(part); expect(result.part.signature).toBe("short"); expect(result.stripped).toBe(0); }); it("handles parts without signature", () => { const part = { type: "text", text: "Hello" }; const result = stripClaudeThinkingFields(part); expect(result.part).toEqual({ type: "text", text: "Hello" }); expect(result.stripped).toBe(0); }); }); describe("deepSanitizeCrossModelMetadata", () => { it("sanitizes contents array (Gemini format)", () => { const payload = { contents: [ { role: "model", parts: [ { thought: true, text: "thinking...", thoughtSignature: "sig1", }, { functionCall: { name: "bash" }, metadata: { google: { thoughtSignature: "sig2" } }, }, ], }, ], }; const result = deepSanitizeCrossModelMetadata(payload, "claude"); const parts = (result.obj as any).contents[0].parts; expect(parts[0].thoughtSignature).toBeUndefined(); expect(parts[1].metadata?.google?.thoughtSignature).toBeUndefined(); expect(parts[1].functionCall.name).toBe("bash"); expect(result.stripped).toBe(2); }); it("sanitizes messages array (Anthropic format)", () => { const payload = { messages: [ { role: "assistant", content: [ { type: "thinking", thinking: "analyzing...", signature: "a".repeat(60), }, { type: "tool_use", id: "tool_1", name: "bash" }, ], }, ], }; const result = deepSanitizeCrossModelMetadata(payload, "gemini"); const content = (result.obj as any).messages[0].content; expect(content[0].signature).toBeUndefined(); expect(content[1].name).toBe("bash"); expect(result.stripped).toBe(1); }); it("sanitizes extra_body.messages", () => { const payload = { extra_body: { messages: [ { role: "assistant", content: [ { type: "tool_use", metadata: { google: { thoughtSignature: "sig" } }, }, ], }, ], }, }; const result = deepSanitizeCrossModelMetadata(payload, "claude"); const content = (result.obj as any).extra_body.messages[0].content; expect(content[0].metadata?.google?.thoughtSignature).toBeUndefined(); expect(result.stripped).toBe(1); }); it("handles nested requests array (batch format)", () => { const payload = { requests: [ { contents: [ { role: "model", parts: [{ thoughtSignature: "sig1" }], }, ], }, { contents: [ { role: "model", parts: [{ thoughtSignature: "sig2" }], }, ], }, ], }; const result = deepSanitizeCrossModelMetadata(payload, "claude"); expect(result.stripped).toBe(2); }); }); describe("sanitizeCrossModelPayload", () => { it("strips Gemini signatures when target is Claude", () => { const payload = { contents: [ { role: "model", parts: [ { thought: true, text: "thinking...", thoughtSignature: "sig1" }, { functionCall: { name: "bash" }, metadata: { google: { thoughtSignature: "sig2" } }, }, ], }, ], }; const result = sanitizeCrossModelPayload(payload, { targetModel: "claude-opus-4-6-thinking-medium", }); expect(result.modified).toBe(true); expect(result.signaturesStripped).toBe(2); const parts = (result.payload as any).contents[0].parts; expect(parts[0].thoughtSignature).toBeUndefined(); expect(parts[1].metadata?.google?.thoughtSignature).toBeUndefined(); }); it("strips Claude signatures when target is Gemini", () => { const payload = { messages: [ { role: "assistant", content: [ { type: "thinking", thinking: "analyzing...", signature: "a".repeat(60), }, ], }, ], }; const result = sanitizeCrossModelPayload(payload, { targetModel: "gemini-3-pro-low", }); expect(result.modified).toBe(true); expect(result.signaturesStripped).toBe(1); }); it("skips sanitization for unknown target model", () => { const payload = { contents: [ { parts: [{ thoughtSignature: "sig" }], }, ], }; const result = sanitizeCrossModelPayload(payload, { targetModel: "gpt-4", }); expect(result.modified).toBe(false); expect(result.signaturesStripped).toBe(0); expect((result.payload as any).contents[0].parts[0].thoughtSignature).toBe("sig"); }); it("preserves functionCall structure", () => { const payload = { contents: [ { role: "model", parts: [ { functionCall: { name: "Bash", args: { command: "df -h", description: "Check disk space" }, }, metadata: { google: { thoughtSignature: "sig" } }, }, ], }, ], }; const result = sanitizeCrossModelPayload(payload, { targetModel: "claude-opus-4-6-thinking-low", }); const fc = (result.payload as any).contents[0].parts[0].functionCall; expect(fc.name).toBe("Bash"); expect(fc.args.command).toBe("df -h"); }); it("preserves non-signature metadata when option is true", () => { const payload = { contents: [ { parts: [ { functionCall: { name: "read" }, metadata: { google: { thoughtSignature: "strip-me", groundingMetadata: "keep-me", }, cache_control: { type: "ephemeral" }, }, }, ], }, ], }; const result = sanitizeCrossModelPayload(payload, { targetModel: "claude-sonnet-4", preserveNonSignatureMetadata: true, }); const meta = (result.payload as any).contents[0].parts[0].metadata; expect(meta.google.thoughtSignature).toBeUndefined(); expect(meta.google.groundingMetadata).toBe("keep-me"); expect(meta.cache_control.type).toBe("ephemeral"); }); }); describe("sanitizeCrossModelPayloadInPlace", () => { it("mutates payload directly", () => { const payload = { contents: [ { parts: [ { thought: true, thoughtSignature: "sig", }, ], }, ], }; const stripped = sanitizeCrossModelPayloadInPlace( payload as Record, { targetModel: "claude-opus-4-6-thinking-high" } ); expect(stripped).toBe(1); expect((payload as any).contents[0].parts[0].thoughtSignature).toBeUndefined(); }); it("handles extra_body.messages", () => { const payload = { extra_body: { messages: [ { content: [{ metadata: { google: { thoughtSignature: "sig" } } }], }, ], }, }; const stripped = sanitizeCrossModelPayloadInPlace( payload as Record, { targetModel: "claude-sonnet-4" } ); expect(stripped).toBe(1); }); }); describe("real-world reproduction scenario", () => { it("handles Gemini thinking + tool call -> Claude tool call scenario", () => { const geminiSessionHistory = { contents: [ { role: "user", parts: [ { text: "Check disk space. Think about which filesystems are most utilized.", }, ], }, { role: "model", parts: [ { thought: true, text: "I need to analyze disk usage by running df -h...", thoughtSignature: "EsgQCsUQAXLI2nybuafAE150LGTo2r78fakesig123", }, { functionCall: { name: "Bash", args: { command: "df -h", description: "Check disk space" }, }, metadata: { google: { thoughtSignature: "EsgQCsUQAXLI2nybuafAE150LGTo2r78fakesig123", }, }, }, ], }, { role: "function", parts: [ { functionResponse: { name: "Bash", response: { output: "Filesystem Size Used Avail Use%...", }, }, }, ], }, { role: "model", parts: [{ text: "The root filesystem is 62% utilized..." }], }, { role: "user", parts: [{ text: "Now check memory usage with free -h" }], }, ], }; const result = sanitizeCrossModelPayload(geminiSessionHistory, { targetModel: "claude-opus-4-6-thinking-medium", }); expect(result.modified).toBe(true); expect(result.signaturesStripped).toBe(2); const modelParts = (result.payload as any).contents[1].parts; expect(modelParts[0].thoughtSignature).toBeUndefined(); expect(modelParts[0].thought).toBe(true); expect(modelParts[0].text).toContain("analyze disk usage"); expect(modelParts[1].metadata?.google?.thoughtSignature).toBeUndefined(); expect(modelParts[1].functionCall.name).toBe("Bash"); expect(modelParts[1].functionCall.args.command).toBe("df -h"); const functionResponse = (result.payload as any).contents[2].parts[0] .functionResponse; expect(functionResponse.name).toBe("Bash"); }); it("handles Claude thinking + tool use -> Gemini tool call scenario", () => { const claudeSessionHistory = { messages: [ { role: "user", content: [{ type: "text", text: "List files" }], }, { role: "assistant", content: [ { type: "thinking", thinking: "I should list the files...", signature: "a".repeat(100), }, { type: "tool_use", id: "tool_abc123", name: "bash", input: { command: "ls -la" }, }, ], }, { role: "user", content: [ { type: "tool_result", tool_use_id: "tool_abc123", content: "file1.txt\nfile2.txt", }, ], }, ], }; const result = sanitizeCrossModelPayload(claudeSessionHistory, { targetModel: "gemini-3-flash", }); expect(result.modified).toBe(true); expect(result.signaturesStripped).toBe(1); const assistantContent = (result.payload as any).messages[1].content; expect(assistantContent[0].signature).toBeUndefined(); expect(assistantContent[0].thinking).toContain("list the files"); expect(assistantContent[1].name).toBe("bash"); }); }); }); ================================================ FILE: src/plugin/transform/cross-model-sanitizer.ts ================================================ /** * Cross-Model Metadata Sanitization * * Fixes: "Invalid `signature` in `thinking` block" error when switching models mid-session. * * Root cause: Gemini stores thoughtSignature in metadata.google, Claude stores signature * in top-level thinking blocks. Foreign signatures fail validation on the target model. */ import { isClaudeModel } from "./claude"; import { isGeminiModel } from "./gemini"; export type ModelFamily = "claude" | "gemini" | "unknown"; export interface SanitizerOptions { targetModel: string; sourceModel?: string; preserveNonSignatureMetadata?: boolean; } export interface SanitizationResult { payload: unknown; modified: boolean; signaturesStripped: number; } const GEMINI_SIGNATURE_FIELDS = ["thoughtSignature", "thinkingMetadata"] as const; const CLAUDE_SIGNATURE_FIELDS = ["signature"] as const; export function getModelFamily(model: string): ModelFamily { if (isClaudeModel(model)) return "claude"; if (isGeminiModel(model)) return "gemini"; return "unknown"; } function isPlainObject(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } export function stripGeminiThinkingMetadata( part: Record, preserveNonSignature = true ): { part: Record; stripped: number } { let stripped = 0; if ("thoughtSignature" in part) { delete part.thoughtSignature; stripped++; } if ("thinkingMetadata" in part) { delete part.thinkingMetadata; stripped++; } if (isPlainObject(part.metadata)) { const metadata = part.metadata as Record; if (isPlainObject(metadata.google)) { const google = metadata.google as Record; for (const field of GEMINI_SIGNATURE_FIELDS) { if (field in google) { delete google[field]; stripped++; } } if (!preserveNonSignature || Object.keys(google).length === 0) { delete metadata.google; } if (Object.keys(metadata).length === 0) { delete part.metadata; } } } return { part, stripped }; } export function stripClaudeThinkingFields( part: Record ): { part: Record; stripped: number } { let stripped = 0; if (part.type === "thinking" || part.type === "redacted_thinking") { for (const field of CLAUDE_SIGNATURE_FIELDS) { if (field in part) { delete part[field]; stripped++; } } } if ("signature" in part && typeof part.signature === "string") { if ((part.signature as string).length >= 50) { delete part.signature; stripped++; } } return { part, stripped }; } function sanitizePart( part: unknown, targetFamily: ModelFamily, preserveNonSignature: boolean ): { part: unknown; stripped: number } { if (!isPlainObject(part)) { return { part, stripped: 0 }; } let totalStripped = 0; const partObj = { ...part } as Record; if (targetFamily === "claude") { const result = stripGeminiThinkingMetadata(partObj, preserveNonSignature); totalStripped += result.stripped; } else if (targetFamily === "gemini") { const result = stripClaudeThinkingFields(partObj); totalStripped += result.stripped; } return { part: partObj, stripped: totalStripped }; } function sanitizeParts( parts: unknown[], targetFamily: ModelFamily, preserveNonSignature: boolean ): { parts: unknown[]; stripped: number } { let totalStripped = 0; const sanitizedParts = parts.map((part) => { const result = sanitizePart(part, targetFamily, preserveNonSignature); totalStripped += result.stripped; return result.part; }); return { parts: sanitizedParts, stripped: totalStripped }; } function sanitizeContents( contents: unknown[], targetFamily: ModelFamily, preserveNonSignature: boolean ): { contents: unknown[]; stripped: number } { let totalStripped = 0; const sanitizedContents = contents.map((content) => { if (!isPlainObject(content)) return content; const contentObj = { ...content } as Record; if (Array.isArray(contentObj.parts)) { const result = sanitizeParts( contentObj.parts, targetFamily, preserveNonSignature ); contentObj.parts = result.parts; totalStripped += result.stripped; } return contentObj; }); return { contents: sanitizedContents, stripped: totalStripped }; } function sanitizeMessages( messages: unknown[], targetFamily: ModelFamily, preserveNonSignature: boolean ): { messages: unknown[]; stripped: number } { let totalStripped = 0; const sanitizedMessages = messages.map((message) => { if (!isPlainObject(message)) return message; const messageObj = { ...message } as Record; if (Array.isArray(messageObj.content)) { const result = sanitizeParts( messageObj.content, targetFamily, preserveNonSignature ); messageObj.content = result.parts; totalStripped += result.stripped; } return messageObj; }); return { messages: sanitizedMessages, stripped: totalStripped }; } export function deepSanitizeCrossModelMetadata( obj: unknown, targetFamily: ModelFamily, preserveNonSignature = true ): { obj: unknown; stripped: number } { if (!isPlainObject(obj)) { return { obj, stripped: 0 }; } let totalStripped = 0; const result = { ...obj } as Record; if (Array.isArray(result.contents)) { const sanitized = sanitizeContents( result.contents, targetFamily, preserveNonSignature ); result.contents = sanitized.contents; totalStripped += sanitized.stripped; } if (Array.isArray(result.messages)) { const sanitized = sanitizeMessages( result.messages, targetFamily, preserveNonSignature ); result.messages = sanitized.messages; totalStripped += sanitized.stripped; } if (isPlainObject(result.extra_body)) { const extraBody = { ...result.extra_body } as Record; if (Array.isArray(extraBody.messages)) { const sanitized = sanitizeMessages( extraBody.messages, targetFamily, preserveNonSignature ); extraBody.messages = sanitized.messages; totalStripped += sanitized.stripped; } result.extra_body = extraBody; } if (Array.isArray(result.requests)) { const sanitizedRequests = result.requests.map((req) => { const sanitized = deepSanitizeCrossModelMetadata( req, targetFamily, preserveNonSignature ); totalStripped += sanitized.stripped; return sanitized.obj; }); result.requests = sanitizedRequests; } return { obj: result, stripped: totalStripped }; } export function sanitizeCrossModelPayload( payload: unknown, options: SanitizerOptions ): SanitizationResult { const targetFamily = getModelFamily(options.targetModel); if (targetFamily === "unknown") { return { payload, modified: false, signaturesStripped: 0, }; } const preserveNonSignature = options.preserveNonSignatureMetadata ?? true; const result = deepSanitizeCrossModelMetadata( payload, targetFamily, preserveNonSignature ); return { payload: result.obj, modified: result.stripped > 0, signaturesStripped: result.stripped, }; } export function sanitizeCrossModelPayloadInPlace( payload: Record, options: SanitizerOptions ): number { const targetFamily = getModelFamily(options.targetModel); if (targetFamily === "unknown") { return 0; } const preserveNonSignature = options.preserveNonSignatureMetadata ?? true; let totalStripped = 0; const sanitizePartsInPlace = (parts: unknown[]): void => { for (const part of parts) { if (!isPlainObject(part)) continue; if (targetFamily === "claude") { const result = stripGeminiThinkingMetadata( part as Record, preserveNonSignature ); totalStripped += result.stripped; } else if (targetFamily === "gemini") { const result = stripClaudeThinkingFields( part as Record ); totalStripped += result.stripped; } } }; if (Array.isArray(payload.contents)) { for (const content of payload.contents) { if (isPlainObject(content) && Array.isArray(content.parts)) { sanitizePartsInPlace(content.parts); } } } if (Array.isArray(payload.messages)) { for (const message of payload.messages) { if (isPlainObject(message) && Array.isArray(message.content)) { sanitizePartsInPlace(message.content); } } } if (isPlainObject(payload.extra_body)) { const extraBody = payload.extra_body as Record; if (Array.isArray(extraBody.messages)) { for (const message of extraBody.messages) { if (isPlainObject(message) && Array.isArray(message.content)) { sanitizePartsInPlace(message.content); } } } } return totalStripped; } ================================================ FILE: src/plugin/transform/gemini.test.ts ================================================ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import { isGeminiModel, isGemini3Model, isGemini25Model, isImageGenerationModel, buildGemini3ThinkingConfig, buildGemini25ThinkingConfig, buildImageGenerationConfig, normalizeGeminiTools, applyGeminiTransforms, toGeminiSchema, wrapToolsAsFunctionDeclarations, } from "./gemini"; import type { RequestPayload } from "./types"; describe("transform/gemini", () => { describe("isGeminiModel", () => { it("returns true for gemini-pro", () => { expect(isGeminiModel("gemini-pro")).toBe(true); }); it("returns true for gemini-1.5-pro", () => { expect(isGeminiModel("gemini-1.5-pro")).toBe(true); }); it("returns true for gemini-2.5-flash", () => { expect(isGeminiModel("gemini-2.5-flash")).toBe(true); }); it("returns true for gemini-3-pro-high", () => { expect(isGeminiModel("gemini-3-pro-high")).toBe(true); }); it("returns true for uppercase GEMINI-PRO", () => { expect(isGeminiModel("GEMINI-PRO")).toBe(true); }); it("returns true for mixed case Gemini-Pro", () => { expect(isGeminiModel("Gemini-Pro")).toBe(true); }); it("returns false for claude-3-opus", () => { expect(isGeminiModel("claude-3-opus")).toBe(false); }); it("returns false for gpt-4", () => { expect(isGeminiModel("gpt-4")).toBe(false); }); it("returns false for gemini-claude hybrid (contains both)", () => { expect(isGeminiModel("gemini-claude-hybrid")).toBe(false); }); it("returns false for claude-on-gemini", () => { expect(isGeminiModel("claude-on-gemini")).toBe(false); }); it("returns false for empty string", () => { expect(isGeminiModel("")).toBe(false); }); }); describe("isGemini3Model", () => { it("returns true for gemini-3-pro", () => { expect(isGemini3Model("gemini-3-pro")).toBe(true); }); it("returns true for gemini-3-pro-high", () => { expect(isGemini3Model("gemini-3-pro-high")).toBe(true); }); it("returns true for gemini-3-flash", () => { expect(isGemini3Model("gemini-3-flash")).toBe(true); }); it("returns true for gemini-3.1-pro", () => { expect(isGemini3Model("gemini-3.1-pro")).toBe(true); }); it("returns true for uppercase GEMINI-3-PRO", () => { expect(isGemini3Model("GEMINI-3-PRO")).toBe(true); }); it("returns false for gemini-2.5-pro", () => { expect(isGemini3Model("gemini-2.5-pro")).toBe(false); }); it("returns false for gemini-pro", () => { expect(isGemini3Model("gemini-pro")).toBe(false); }); it("returns false for claude-3-opus", () => { expect(isGemini3Model("claude-3-opus")).toBe(false); }); it("returns false for empty string", () => { expect(isGemini3Model("")).toBe(false); }); }); describe("isGemini25Model", () => { it("returns true for gemini-2.5-pro", () => { expect(isGemini25Model("gemini-2.5-pro")).toBe(true); }); it("returns true for gemini-2.5-flash", () => { expect(isGemini25Model("gemini-2.5-flash")).toBe(true); }); it("returns true for gemini-2.5-pro-preview", () => { expect(isGemini25Model("gemini-2.5-pro-preview")).toBe(true); }); it("returns true for uppercase GEMINI-2.5-PRO", () => { expect(isGemini25Model("GEMINI-2.5-PRO")).toBe(true); }); it("returns false for gemini-3-pro", () => { expect(isGemini25Model("gemini-3-pro")).toBe(false); }); it("returns false for gemini-2.0-flash", () => { expect(isGemini25Model("gemini-2.0-flash")).toBe(false); }); it("returns false for gemini-pro", () => { expect(isGemini25Model("gemini-pro")).toBe(false); }); it("returns false for empty string", () => { expect(isGemini25Model("")).toBe(false); }); }); describe("buildGemini3ThinkingConfig", () => { it("builds config with includeThoughts true and low tier", () => { const config = buildGemini3ThinkingConfig(true, "low"); expect(config).toEqual({ includeThoughts: true, thinkingLevel: "low", }); }); it("builds config with includeThoughts true and medium tier", () => { const config = buildGemini3ThinkingConfig(true, "medium"); expect(config).toEqual({ includeThoughts: true, thinkingLevel: "medium", }); }); it("builds config with includeThoughts true and high tier", () => { const config = buildGemini3ThinkingConfig(true, "high"); expect(config).toEqual({ includeThoughts: true, thinkingLevel: "high", }); }); it("builds config with includeThoughts false", () => { const config = buildGemini3ThinkingConfig(false, "high"); expect(config).toEqual({ includeThoughts: false, thinkingLevel: "high", }); }); }); describe("buildGemini25ThinkingConfig", () => { it("builds config with includeThoughts true and budget", () => { const config = buildGemini25ThinkingConfig(true, 8192); expect(config).toEqual({ includeThoughts: true, thinkingBudget: 8192, }); }); it("builds config with includeThoughts false and budget", () => { const config = buildGemini25ThinkingConfig(false, 16384); expect(config).toEqual({ includeThoughts: false, thinkingBudget: 16384, }); }); it("builds config without budget when undefined", () => { const config = buildGemini25ThinkingConfig(true, undefined); expect(config).toEqual({ includeThoughts: true, }); expect(config).not.toHaveProperty("thinkingBudget"); }); it("builds config without budget when zero", () => { const config = buildGemini25ThinkingConfig(true, 0); expect(config).toEqual({ includeThoughts: true, }); expect(config).not.toHaveProperty("thinkingBudget"); }); it("builds config without budget when negative", () => { const config = buildGemini25ThinkingConfig(true, -1000); expect(config).toEqual({ includeThoughts: true, }); expect(config).not.toHaveProperty("thinkingBudget"); }); it("builds config with large budget", () => { const config = buildGemini25ThinkingConfig(true, 100000); expect(config).toEqual({ includeThoughts: true, thinkingBudget: 100000, }); }); }); describe("normalizeGeminiTools", () => { it("returns empty debug info when tools is not an array", () => { const payload: RequestPayload = { contents: [] }; const result = normalizeGeminiTools(payload); expect(result).toEqual({ toolDebugMissing: 0, toolDebugSummaries: [], }); }); it("returns empty debug info when tools is undefined", () => { const payload: RequestPayload = { contents: [], tools: undefined }; const result = normalizeGeminiTools(payload); expect(result).toEqual({ toolDebugMissing: 0, toolDebugSummaries: [], }); }); it("normalizes tool with function.input_schema", () => { const payload: RequestPayload = { contents: [], tools: [ { function: { name: "test_tool", description: "A test tool", input_schema: { type: "object", properties: { foo: { type: "string" } } }, }, }, ], }; const result = normalizeGeminiTools(payload); expect(result.toolDebugMissing).toBe(0); expect(result.toolDebugSummaries).toHaveLength(1); expect((payload.tools as unknown[])[0]).not.toHaveProperty("custom"); }); it("normalizes tool with function.parameters", () => { const payload: RequestPayload = { contents: [], tools: [ { function: { name: "test_tool", description: "A test tool", parameters: { type: "object", properties: { bar: { type: "number" } } }, }, }, ], }; const result = normalizeGeminiTools(payload); expect(result.toolDebugMissing).toBe(0); }); it("creates custom from function and strips it for Gemini", () => { const payload: RequestPayload = { contents: [], tools: [ { function: { name: "my_func", description: "My function", input_schema: { type: "object" }, }, }, ], }; normalizeGeminiTools(payload); expect((payload.tools as unknown[])[0]).not.toHaveProperty("custom"); expect((payload.tools as unknown[])[0]).toHaveProperty("function"); }); it("creates custom when both function and custom are missing", () => { const payload: RequestPayload = { contents: [], tools: [ { name: "standalone_tool", description: "A standalone tool", parameters: { type: "object", properties: {} }, }, ], }; normalizeGeminiTools(payload); expect((payload.tools as unknown[])[0]).not.toHaveProperty("custom"); }); it("counts missing schemas", () => { const payload: RequestPayload = { contents: [], tools: [ { name: "tool1" }, { name: "tool2" }, { function: { name: "tool3", input_schema: { type: "object" } } }, ], }; const result = normalizeGeminiTools(payload); expect(result.toolDebugMissing).toBe(2); }); it("generates debug summaries for each tool", () => { const payload: RequestPayload = { contents: [], tools: [ { function: { name: "t1", input_schema: { type: "object" } } }, { function: { name: "t2", input_schema: { type: "object" } } }, ], }; const result = normalizeGeminiTools(payload); expect(result.toolDebugSummaries).toHaveLength(2); expect(result.toolDebugSummaries[0]).toContain("idx=0"); expect(result.toolDebugSummaries[1]).toContain("idx=1"); }); it("uses default tool name when name is missing", () => { const payload: RequestPayload = { contents: [], tools: [{}], }; const result = normalizeGeminiTools(payload); expect(result.toolDebugSummaries[0]).toContain("idx=0"); }); it("extracts schema from custom.input_schema", () => { const payload: RequestPayload = { contents: [], tools: [ { custom: { name: "custom_tool", input_schema: { type: "object", properties: { x: { type: "string" } } }, }, }, ], }; normalizeGeminiTools(payload); expect((payload.tools as unknown[])[0]).not.toHaveProperty("custom"); }); it("extracts schema from inputSchema (camelCase)", () => { const payload: RequestPayload = { contents: [], tools: [ { name: "camel_tool", inputSchema: { type: "object", properties: { y: { type: "boolean" } } }, }, ], }; normalizeGeminiTools(payload); expect((payload.tools as unknown[])[0]).not.toHaveProperty("custom"); }); }); describe("applyGeminiTransforms", () => { it("applies Gemini 3 thinking config with thinkingLevel", () => { const payload: RequestPayload = { contents: [] }; applyGeminiTransforms(payload, { model: "gemini-3-pro-high", tierThinkingLevel: "high", normalizedThinking: { includeThoughts: true }, }); const genConfig = payload.generationConfig as Record; expect(genConfig.thinkingConfig).toEqual({ includeThoughts: true, thinkingLevel: "high", }); }); it("applies Gemini 2.5 thinking config with thinkingBudget", () => { const payload: RequestPayload = { contents: [] }; applyGeminiTransforms(payload, { model: "gemini-2.5-flash", tierThinkingBudget: 8192, normalizedThinking: { includeThoughts: true }, }); const genConfig = payload.generationConfig as Record; expect(genConfig.thinkingConfig).toEqual({ includeThoughts: true, thinkingBudget: 8192, }); }); it("prefers tierThinkingBudget over normalizedThinking.thinkingBudget", () => { const payload: RequestPayload = { contents: [] }; applyGeminiTransforms(payload, { model: "gemini-2.5-pro", tierThinkingBudget: 16384, normalizedThinking: { includeThoughts: true, thinkingBudget: 8192 }, }); const genConfig = payload.generationConfig as Record; expect((genConfig.thinkingConfig as Record).thinkingBudget).toBe(16384); }); it("falls back to normalizedThinking.thinkingBudget when tierThinkingBudget is undefined", () => { const payload: RequestPayload = { contents: [] }; applyGeminiTransforms(payload, { model: "gemini-2.5-pro", normalizedThinking: { includeThoughts: true, thinkingBudget: 4096 }, }); const genConfig = payload.generationConfig as Record; expect((genConfig.thinkingConfig as Record).thinkingBudget).toBe(4096); }); it("does not apply thinking config when normalizedThinking is undefined", () => { const payload: RequestPayload = { contents: [] }; applyGeminiTransforms(payload, { model: "gemini-3-pro", }); expect(payload.generationConfig).toBeUndefined(); }); it("preserves existing generationConfig properties", () => { const payload: RequestPayload = { contents: [], generationConfig: { temperature: 0.7, maxOutputTokens: 1000 }, }; applyGeminiTransforms(payload, { model: "gemini-3-pro-medium", tierThinkingLevel: "medium", normalizedThinking: { includeThoughts: true }, }); const genConfig = payload.generationConfig as Record; expect(genConfig.temperature).toBe(0.7); expect(genConfig.maxOutputTokens).toBe(1000); expect(genConfig.thinkingConfig).toBeDefined(); }); it("normalizes tools and returns debug info", () => { const payload: RequestPayload = { contents: [], tools: [ { function: { name: "tool1", input_schema: { type: "object" } } }, { name: "tool2" }, ], }; const result = applyGeminiTransforms(payload, { model: "gemini-2.5-flash", }); expect(result.toolDebugSummaries).toHaveLength(2); expect(result.toolDebugMissing).toBe(1); }); it("defaults includeThoughts to true when not specified", () => { const payload: RequestPayload = { contents: [] }; applyGeminiTransforms(payload, { model: "gemini-3-pro-low", tierThinkingLevel: "low", normalizedThinking: {}, }); const genConfig = payload.generationConfig as Record; expect((genConfig.thinkingConfig as Record).includeThoughts).toBe(true); }); it("respects includeThoughts false", () => { const payload: RequestPayload = { contents: [] }; applyGeminiTransforms(payload, { model: "gemini-3-pro-high", tierThinkingLevel: "high", normalizedThinking: { includeThoughts: false }, }); const genConfig = payload.generationConfig as Record; expect((genConfig.thinkingConfig as Record).includeThoughts).toBe(false); }); it("handles Gemini 2.5 without tierThinkingBudget or normalizedThinking.thinkingBudget", () => { const payload: RequestPayload = { contents: [] }; applyGeminiTransforms(payload, { model: "gemini-2.5-pro", normalizedThinking: { includeThoughts: true }, }); const genConfig = payload.generationConfig as Record; const thinkingConfig = genConfig.thinkingConfig as Record; expect(thinkingConfig.includeThoughts).toBe(true); expect(thinkingConfig).not.toHaveProperty("thinkingBudget"); }); describe("Google Search (Grounding)", () => { it("injects googleSearch tool when mode is 'auto'", () => { const payload: RequestPayload = { contents: [], tools: [] }; applyGeminiTransforms(payload, { model: "gemini-3-pro", googleSearch: { mode: "auto" }, }); const tools = payload.tools as unknown[]; expect(tools).toHaveLength(1); expect(tools[0]).toEqual({ googleSearch: {}, }); }); it("ignores threshold value (deprecated in new API)", () => { const payload: RequestPayload = { contents: [] }; applyGeminiTransforms(payload, { model: "gemini-3-flash", googleSearch: { mode: "auto", threshold: 0.7 }, }); const tools = payload.tools as unknown[]; const searchTool = tools[0] as Record; // New API uses simple googleSearch: {} without threshold expect(searchTool).toEqual({ googleSearch: {} }); }); it("works without threshold specified", () => { const payload: RequestPayload = { contents: [] }; applyGeminiTransforms(payload, { model: "gemini-3-pro", googleSearch: { mode: "auto" }, }); const tools = payload.tools as unknown[]; const searchTool = tools[0] as Record; expect(searchTool).toEqual({ googleSearch: {} }); }); it("does not inject search tool when mode is 'off'", () => { const payload: RequestPayload = { contents: [], tools: [] }; applyGeminiTransforms(payload, { model: "gemini-3-pro", googleSearch: { mode: "off" }, }); const tools = payload.tools as unknown[]; expect(tools).toHaveLength(0); }); it("does not inject search tool when googleSearch is undefined", () => { const payload: RequestPayload = { contents: [], tools: [] }; applyGeminiTransforms(payload, { model: "gemini-3-pro", }); const tools = payload.tools as unknown[]; expect(tools).toHaveLength(0); }); it("appends search tool to existing tools array", () => { const payload: RequestPayload = { contents: [], tools: [ { function: { name: "existing_tool", input_schema: { type: "object" } } }, ], }; applyGeminiTransforms(payload, { model: "gemini-3-pro", googleSearch: { mode: "auto" }, }); const tools = payload.tools as unknown[]; expect(tools).toHaveLength(2); const lastTool = tools[1] as Record; expect(lastTool).toHaveProperty("googleSearch"); }); it("search tool is not normalized (skipped by normalizeGeminiTools)", () => { const payload: RequestPayload = { contents: [] }; applyGeminiTransforms(payload, { model: "gemini-3-pro", googleSearch: { mode: "auto" }, }); const tools = payload.tools as unknown[]; const searchTool = tools[0] as Record; expect(searchTool).toHaveProperty("googleSearch"); expect(searchTool).not.toHaveProperty("function"); expect(searchTool).not.toHaveProperty("custom"); }); }); }); describe("isImageGenerationModel", () => { it("returns true for gemini-3-pro-image", () => { expect(isImageGenerationModel("gemini-3-pro-image")).toBe(true); }); it("returns true for gemini-3-pro-image-preview", () => { expect(isImageGenerationModel("gemini-3-pro-image-preview")).toBe(true); }); it("returns true for gemini-2.5-flash-image", () => { expect(isImageGenerationModel("gemini-2.5-flash-image")).toBe(true); }); it("returns true for imagen-3", () => { expect(isImageGenerationModel("imagen-3")).toBe(true); }); it("returns true for uppercase GEMINI-3-PRO-IMAGE", () => { expect(isImageGenerationModel("GEMINI-3-PRO-IMAGE")).toBe(true); }); it("returns false for gemini-3-pro", () => { expect(isImageGenerationModel("gemini-3-pro")).toBe(false); }); it("returns false for gemini-2.5-flash", () => { expect(isImageGenerationModel("gemini-2.5-flash")).toBe(false); }); it("returns false for claude-sonnet-4-6", () => { expect(isImageGenerationModel("claude-sonnet-4-6")).toBe(false); }); }); describe("buildImageGenerationConfig", () => { const originalEnv = process.env; beforeEach(() => { // Reset environment before each test vi.resetModules(); process.env = { ...originalEnv }; }); afterEach(() => { process.env = originalEnv; }); it("returns default 1:1 aspect ratio when no env var set", () => { delete process.env.OPENCODE_IMAGE_ASPECT_RATIO; const config = buildImageGenerationConfig(); expect(config).toEqual({ aspectRatio: "1:1" }); }); it("uses OPENCODE_IMAGE_ASPECT_RATIO env var when set to valid value", () => { process.env.OPENCODE_IMAGE_ASPECT_RATIO = "16:9"; const config = buildImageGenerationConfig(); expect(config).toEqual({ aspectRatio: "16:9" }); }); it("accepts all valid aspect ratios", () => { const validRatios = ["1:1", "2:3", "3:2", "3:4", "4:3", "4:5", "5:4", "9:16", "16:9", "21:9"]; for (const ratio of validRatios) { process.env.OPENCODE_IMAGE_ASPECT_RATIO = ratio; const config = buildImageGenerationConfig(); expect(config.aspectRatio).toBe(ratio); } }); it("falls back to 1:1 for invalid aspect ratio", () => { process.env.OPENCODE_IMAGE_ASPECT_RATIO = "invalid"; const config = buildImageGenerationConfig(); expect(config).toEqual({ aspectRatio: "1:1" }); }); it("falls back to 1:1 for unsupported aspect ratio", () => { process.env.OPENCODE_IMAGE_ASPECT_RATIO = "5:3"; const config = buildImageGenerationConfig(); expect(config).toEqual({ aspectRatio: "1:1" }); }); }); describe("toGeminiSchema", () => { it("returns null/undefined as-is", () => { expect(toGeminiSchema(null)).toBe(null); expect(toGeminiSchema(undefined)).toBe(undefined); }); it("returns primitives as-is", () => { expect(toGeminiSchema("string")).toBe("string"); expect(toGeminiSchema(123)).toBe(123); expect(toGeminiSchema(true)).toBe(true); }); it("returns arrays as-is", () => { const arr = [1, 2, 3]; expect(toGeminiSchema(arr)).toBe(arr); }); it("converts type to uppercase", () => { expect(toGeminiSchema({ type: "object" })).toEqual({ type: "OBJECT" }); expect(toGeminiSchema({ type: "string" })).toEqual({ type: "STRING" }); expect(toGeminiSchema({ type: "boolean" })).toEqual({ type: "BOOLEAN" }); expect(toGeminiSchema({ type: "number" })).toEqual({ type: "NUMBER" }); expect(toGeminiSchema({ type: "integer" })).toEqual({ type: "INTEGER" }); expect(toGeminiSchema({ type: "array" })).toEqual({ type: "ARRAY", items: { type: "STRING" } }); }); it("removes additionalProperties field", () => { const schema = { type: "object", properties: { foo: { type: "string" } }, additionalProperties: false, }; const result = toGeminiSchema(schema) as Record; expect(result).not.toHaveProperty("additionalProperties"); expect(result.type).toBe("OBJECT"); }); it("removes $schema field", () => { const schema = { $schema: "http://json-schema.org/draft-07/schema#", type: "object", }; const result = toGeminiSchema(schema) as Record; expect(result).not.toHaveProperty("$schema"); expect(result.type).toBe("OBJECT"); }); it("removes $id and $comment fields", () => { const schema = { $id: "my-schema", $comment: "This is a comment", type: "object", }; const result = toGeminiSchema(schema) as Record; expect(result).not.toHaveProperty("$id"); expect(result).not.toHaveProperty("$comment"); expect(result.type).toBe("OBJECT"); }); it("recursively transforms properties", () => { const schema = { type: "object", properties: { name: { type: "string" }, age: { type: "number" }, active: { type: "boolean" }, }, }; const result = toGeminiSchema(schema) as Record; const props = result.properties as Record>; expect(props["name"]!.type).toBe("STRING"); expect(props["age"]!.type).toBe("NUMBER"); expect(props["active"]!.type).toBe("BOOLEAN"); }); it("transforms nested objects recursively", () => { const schema = { type: "object", properties: { user: { type: "object", properties: { email: { type: "string" }, }, additionalProperties: false, }, }, }; const result = toGeminiSchema(schema) as Record; const props = result.properties as Record>; expect(props["user"]!.type).toBe("OBJECT"); expect(props["user"]).not.toHaveProperty("additionalProperties"); const userProps = props["user"]!.properties as Record>; expect(userProps["email"]!.type).toBe("STRING"); }); it("transforms array items schema", () => { const schema = { type: "array", items: { type: "object", properties: { id: { type: "number" }, }, }, }; const result = toGeminiSchema(schema) as Record; expect(result.type).toBe("ARRAY"); const items = result.items as Record; expect(items.type).toBe("OBJECT"); const itemProps = items.properties as Record>; expect(itemProps["id"]!.type).toBe("NUMBER"); }); it("transforms anyOf schemas", () => { const schema = { anyOf: [ { type: "string" }, { type: "number" }, ], }; const result = toGeminiSchema(schema) as Record; const anyOf = result.anyOf as Array>; expect(anyOf[0]!.type).toBe("STRING"); expect(anyOf[1]!.type).toBe("NUMBER"); }); it("transforms oneOf schemas", () => { const schema = { oneOf: [ { type: "boolean" }, { type: "string" }, ], }; const result = toGeminiSchema(schema) as Record; const oneOf = result.oneOf as Array>; expect(oneOf[0]!.type).toBe("BOOLEAN"); expect(oneOf[1]!.type).toBe("STRING"); }); it("transforms allOf schemas", () => { const schema = { allOf: [ { type: "object", properties: { a: { type: "string" } } }, { properties: { b: { type: "number" } } }, ], }; const result = toGeminiSchema(schema) as Record; const allOf = result.allOf as Array>; expect(allOf[0]!.type).toBe("OBJECT"); const props0 = allOf[0]!.properties as Record>; expect(props0["a"]!.type).toBe("STRING"); const props1 = allOf[1]!.properties as Record>; expect(props1["b"]!.type).toBe("NUMBER"); }); it("preserves enum values", () => { const schema = { type: "string", enum: ["low", "medium", "high"], }; const result = toGeminiSchema(schema) as Record; expect(result.type).toBe("STRING"); expect(result.enum).toEqual(["low", "medium", "high"]); }); it("preserves required array when all properties exist", () => { const schema = { type: "object", properties: { name: { type: "string" }, }, required: ["name"], }; const result = toGeminiSchema(schema) as Record; expect(result.required).toEqual(["name"]); }); it("filters required array to only include existing properties", () => { // This fixes: "parameters.required[X]: property is not defined" const schema = { type: "object", properties: { name: { type: "string" }, age: { type: "number" }, }, required: ["name", "nonexistent", "age", "alsoMissing"], }; const result = toGeminiSchema(schema) as Record; expect(result.required).toEqual(["name", "age"]); }); it("omits required field when no valid properties remain", () => { const schema = { type: "object", properties: { name: { type: "string" }, }, required: ["nonexistent", "alsoMissing"], }; const result = toGeminiSchema(schema) as Record; expect(result).not.toHaveProperty("required"); }); it("handles MCP tool with missing properties in required (issue #161)", () => { // Simulates the group_execute_tool schema from issue #161 const schema = { type: "object", properties: { mcp_name: { type: "string", enum: ["exa-mcp-server", "context7"] }, tool_name: { type: "string" }, // Note: "arguments" is missing from properties but present in required }, required: ["mcp_name", "tool_name", "arguments"], }; const result = toGeminiSchema(schema) as Record; // Should filter out "arguments" since it doesn't exist in properties expect(result.required).toEqual(["mcp_name", "tool_name"]); expect(result.type).toBe("OBJECT"); }); it("preserves description", () => { const schema = { type: "string", description: "User's full name", }; const result = toGeminiSchema(schema) as Record; expect(result.description).toBe("User's full name"); }); it("preserves default value", () => { const schema = { type: "number", default: 42, }; const result = toGeminiSchema(schema) as Record; expect(result.default).toBe(42); }); it("handles complex real-world MCP schema", () => { // Simulates a PostHog-like complex schema with enums and nested types const schema = { $schema: "http://json-schema.org/draft-07/schema#", type: "object", properties: { event_name: { type: "string", description: "Event name to track", }, properties: { type: "object", additionalProperties: true, description: "Event properties", }, level: { type: "string", enum: ["info", "warning", "error"], }, items: { type: "array", items: { type: "object", properties: { id: { type: "string" }, value: { type: "number" }, }, additionalProperties: false, }, }, }, required: ["event_name"], additionalProperties: false, }; const result = toGeminiSchema(schema) as Record; // Should remove unsupported fields expect(result).not.toHaveProperty("$schema"); expect(result).not.toHaveProperty("additionalProperties"); // Should uppercase types expect(result.type).toBe("OBJECT"); const props = result.properties as Record>; expect(props["event_name"]!.type).toBe("STRING"); expect(props["properties"]!.type).toBe("OBJECT"); expect(props["properties"]).not.toHaveProperty("additionalProperties"); expect(props["level"]!.type).toBe("STRING"); expect(props["level"]!.enum).toEqual(["info", "warning", "error"]); expect(props["items"]!.type).toBe("ARRAY"); const itemsSchema = props["items"]!.items as Record; expect(itemsSchema.type).toBe("OBJECT"); expect(itemsSchema).not.toHaveProperty("additionalProperties"); const itemProps = itemsSchema.properties as Record>; expect(itemProps["id"]!.type).toBe("STRING"); expect(itemProps["value"]!.type).toBe("NUMBER"); // Should preserve required expect(result.required).toEqual(["event_name"]); }); }); describe("normalizeGeminiTools schema transformation", () => { it("transforms tool schemas to Gemini format with uppercase types", () => { const payload: RequestPayload = { contents: [], tools: [ { function: { name: "test_tool", description: "A test tool", input_schema: { type: "object", properties: { name: { type: "string" }, count: { type: "number" }, }, }, }, }, ], }; normalizeGeminiTools(payload); const tool = (payload.tools as unknown[])[0] as Record; const func = tool.function as Record; const schema = func.input_schema as Record; expect(schema.type).toBe("OBJECT"); const props = schema.properties as Record>; expect(props["name"]!.type).toBe("STRING"); expect(props["count"]!.type).toBe("NUMBER"); }); it("removes additionalProperties from tool schemas", () => { const payload: RequestPayload = { contents: [], tools: [ { function: { name: "strict_tool", input_schema: { type: "object", properties: {}, additionalProperties: false, }, }, }, ], }; normalizeGeminiTools(payload); const tool = (payload.tools as unknown[])[0] as Record; const func = tool.function as Record; const schema = func.input_schema as Record; expect(schema).not.toHaveProperty("additionalProperties"); expect(schema.type).toBe("OBJECT"); }); it("uses uppercase placeholder schema for tools without schemas", () => { const payload: RequestPayload = { contents: [], tools: [{ name: "schema_less_tool" }], }; const result = normalizeGeminiTools(payload); expect(result.toolDebugMissing).toBe(1); // Check that placeholder uses uppercase types const tool = (payload.tools as unknown[])[0] as Record; const params = tool.parameters as Record; expect(params.type).toBe("OBJECT"); const props = params.properties as Record>; expect(props["_placeholder"]!.type).toBe("BOOLEAN"); }); }); describe("wrapToolsAsFunctionDeclarations (fixes #203, #206)", () => { it("wraps tools in functionDeclarations format", () => { const payload: RequestPayload = { contents: [], tools: [ { name: "read_file", description: "Read a file", parameters: { type: "OBJECT", properties: { path: { type: "STRING" } } }, }, ], }; wrapToolsAsFunctionDeclarations(payload); const tools = payload.tools as Array>; expect(tools).toHaveLength(1); expect(tools[0]).toHaveProperty("functionDeclarations"); expect(tools[0]).not.toHaveProperty("parameters"); const decls = tools[0]!.functionDeclarations as Array>; expect(decls).toHaveLength(1); expect(decls[0]!.name).toBe("read_file"); expect(decls[0]!.description).toBe("Read a file"); expect(decls[0]!.parameters).toEqual({ type: "OBJECT", properties: { path: { type: "STRING" } } }); }); it("extracts schema from function.input_schema", () => { const payload: RequestPayload = { contents: [], tools: [ { function: { name: "test_fn", description: "Test function", input_schema: { type: "OBJECT", properties: {} }, }, }, ], }; wrapToolsAsFunctionDeclarations(payload); const tools = payload.tools as Array>; const decls = tools[0]!.functionDeclarations as Array>; expect(decls[0]!.name).toBe("test_fn"); expect(decls[0]!.parameters).toEqual({ type: "OBJECT", properties: {} }); }); it("extracts schema from custom.input_schema", () => { const payload: RequestPayload = { contents: [], tools: [ { custom: { name: "custom_fn", description: "Custom function", input_schema: { type: "OBJECT", properties: { x: { type: "NUMBER" } } }, }, }, ], }; wrapToolsAsFunctionDeclarations(payload); const tools = payload.tools as Array>; const decls = tools[0]!.functionDeclarations as Array>; expect(decls[0]!.name).toBe("custom_fn"); expect(decls[0]!.parameters).toEqual({ type: "OBJECT", properties: { x: { type: "NUMBER" } } }); }); it("preserves googleSearch tools as passthrough (new API)", () => { const payload: RequestPayload = { contents: [], tools: [ { name: "tool1", parameters: { type: "OBJECT", properties: {} } }, { googleSearch: {} }, ], }; wrapToolsAsFunctionDeclarations(payload); const tools = payload.tools as Array>; expect(tools).toHaveLength(2); expect(tools[0]).toHaveProperty("functionDeclarations"); expect(tools[1]).toHaveProperty("googleSearch"); }); it("preserves googleSearchRetrieval tools as passthrough (legacy API)", () => { const payload: RequestPayload = { contents: [], tools: [ { name: "tool1", parameters: { type: "OBJECT", properties: {} } }, { googleSearchRetrieval: { dynamicRetrievalConfig: { mode: "MODE_DYNAMIC", dynamicThreshold: 0.3 }, }, }, ], }; wrapToolsAsFunctionDeclarations(payload); const tools = payload.tools as Array>; expect(tools).toHaveLength(2); expect(tools[0]).toHaveProperty("functionDeclarations"); expect(tools[1]).toHaveProperty("googleSearchRetrieval"); }); it("preserves codeExecution tools as passthrough", () => { const payload: RequestPayload = { contents: [], tools: [ { name: "tool1", parameters: { type: "OBJECT", properties: {} } }, { codeExecution: {} }, ], }; wrapToolsAsFunctionDeclarations(payload); const tools = payload.tools as Array>; expect(tools).toHaveLength(2); expect(tools[0]).toHaveProperty("functionDeclarations"); expect(tools[1]).toHaveProperty("codeExecution"); }); it("merges existing functionDeclarations into output", () => { const payload: RequestPayload = { contents: [], tools: [ { functionDeclarations: [ { name: "existing", description: "Existing fn", parameters: { type: "OBJECT" } }, ], }, { name: "new_tool", parameters: { type: "OBJECT", properties: {} } }, ], }; wrapToolsAsFunctionDeclarations(payload); const tools = payload.tools as Array>; expect(tools).toHaveLength(1); const decls = tools[0]!.functionDeclarations as Array>; expect(decls).toHaveLength(2); expect(decls[0]!.name).toBe("existing"); expect(decls[1]!.name).toBe("new_tool"); }); it("handles multiple tools correctly", () => { const payload: RequestPayload = { contents: [], tools: [ { name: "tool1", description: "First", parameters: { type: "OBJECT" } }, { name: "tool2", description: "Second", parameters: { type: "OBJECT" } }, { name: "tool3", description: "Third", parameters: { type: "OBJECT" } }, ], }; wrapToolsAsFunctionDeclarations(payload); const tools = payload.tools as Array>; expect(tools).toHaveLength(1); const decls = tools[0]!.functionDeclarations as Array>; expect(decls).toHaveLength(3); expect(decls.map(d => d.name)).toEqual(["tool1", "tool2", "tool3"]); }); it("provides default schema when no schema found", () => { const payload: RequestPayload = { contents: [], tools: [{ name: "no_schema_tool" }], }; wrapToolsAsFunctionDeclarations(payload); const tools = payload.tools as Array>; const decls = tools[0]!.functionDeclarations as Array>; expect(decls[0]!.parameters).toEqual({ type: "OBJECT", properties: {} }); }); it("generates default name when missing", () => { const payload: RequestPayload = { contents: [], tools: [{ description: "Anonymous tool", parameters: { type: "OBJECT" } }], }; wrapToolsAsFunctionDeclarations(payload); const tools = payload.tools as Array>; const decls = tools[0]!.functionDeclarations as Array>; expect(decls[0]!.name).toBe("tool-0"); }); it("does nothing when tools is empty", () => { const payload: RequestPayload = { contents: [], tools: [] }; wrapToolsAsFunctionDeclarations(payload); expect(payload.tools).toEqual([]); }); it("does nothing when tools is undefined", () => { const payload: RequestPayload = { contents: [] }; wrapToolsAsFunctionDeclarations(payload); expect(payload.tools).toBeUndefined(); }); }); describe("toGeminiSchema - array items fix (issue #80)", () => { it("adds default items to array schema without items", () => { const schema = { type: "array" }; const result = toGeminiSchema(schema) as Record; expect(result.type).toBe("ARRAY"); expect(result.items).toEqual({ type: "STRING" }); }); it("preserves existing items in array schema", () => { const schema = { type: "array", items: { type: "object", properties: { id: { type: "string" } } }, }; const result = toGeminiSchema(schema) as Record; expect(result.type).toBe("ARRAY"); const items = result.items as Record; expect(items.type).toBe("OBJECT"); const props = items.properties as Record>; expect(props["id"]!.type).toBe("STRING"); }); it("handles nested array without items", () => { const schema = { type: "object", properties: { tags: { type: "array" }, }, }; const result = toGeminiSchema(schema) as Record; const props = result.properties as Record>; expect(props["tags"]!.type).toBe("ARRAY"); expect(props["tags"]!.items).toEqual({ type: "STRING" }); }); }); describe("toGeminiSchema - unsupported fields removal (issue #161)", () => { it("removes $ref field", () => { const schema = { $ref: "#/definitions/MyType", type: "object" }; const result = toGeminiSchema(schema) as Record; expect(result).not.toHaveProperty("$ref"); expect(result.type).toBe("OBJECT"); }); it("removes $defs field", () => { const schema = { type: "object", $defs: { MyType: { type: "string" } }, properties: { name: { type: "string" } }, }; const result = toGeminiSchema(schema) as Record; expect(result).not.toHaveProperty("$defs"); }); it("removes definitions field", () => { const schema = { type: "object", definitions: { MyType: { type: "string" } }, }; const result = toGeminiSchema(schema) as Record; expect(result).not.toHaveProperty("definitions"); }); it("removes const field", () => { const schema = { const: "fixed_value" }; const result = toGeminiSchema(schema) as Record; expect(result).not.toHaveProperty("const"); }); it("removes conditional schema fields (if/then/else/not)", () => { const schema = { type: "object", if: { properties: { type: { const: "a" } } }, then: { properties: { a: { type: "string" } } }, else: { properties: { b: { type: "string" } } }, not: { type: "null" }, }; const result = toGeminiSchema(schema) as Record; expect(result).not.toHaveProperty("if"); expect(result).not.toHaveProperty("then"); expect(result).not.toHaveProperty("else"); expect(result).not.toHaveProperty("not"); }); it("removes patternProperties and propertyNames", () => { const schema = { type: "object", patternProperties: { "^S_": { type: "string" } }, propertyNames: { pattern: "^[a-z]+$" }, }; const result = toGeminiSchema(schema) as Record; expect(result).not.toHaveProperty("patternProperties"); expect(result).not.toHaveProperty("propertyNames"); }); it("removes unevaluatedProperties and unevaluatedItems", () => { const schema = { type: "object", unevaluatedProperties: false, unevaluatedItems: false, }; const result = toGeminiSchema(schema) as Record; expect(result).not.toHaveProperty("unevaluatedProperties"); expect(result).not.toHaveProperty("unevaluatedItems"); }); it("removes contentMediaType and contentEncoding", () => { const schema = { type: "string", contentMediaType: "application/json", contentEncoding: "base64", }; const result = toGeminiSchema(schema) as Record; expect(result).not.toHaveProperty("contentMediaType"); expect(result).not.toHaveProperty("contentEncoding"); }); it("removes dependentRequired and dependentSchemas", () => { const schema = { type: "object", dependentRequired: { credit_card: ["billing_address"] }, dependentSchemas: { name: { properties: { age: { type: "number" } } } }, }; const result = toGeminiSchema(schema) as Record; expect(result).not.toHaveProperty("dependentRequired"); expect(result).not.toHaveProperty("dependentSchemas"); }); it("handles complex MCP schema with all unsupported fields", () => { const complexSchema = { $schema: "http://json-schema.org/draft-07/schema#", $id: "complex-mcp-schema", $comment: "This is a complex schema", $ref: "#/definitions/Base", $defs: { Base: { type: "object" } }, definitions: { Legacy: { type: "string" } }, type: "object", properties: { name: { type: "string", const: "fixed" }, data: { type: "array", items: { type: "object" }, minContains: 1, maxContains: 10, }, }, additionalProperties: false, patternProperties: { "^x-": { type: "string" } }, propertyNames: { minLength: 1 }, unevaluatedProperties: false, if: { properties: { type: { const: "a" } } }, then: { required: ["a"] }, else: { required: ["b"] }, not: { type: "null" }, dependentRequired: { foo: ["bar"] }, dependentSchemas: {}, contentMediaType: "application/json", contentEncoding: "utf-8", required: ["name", "missing_prop"], }; const result = toGeminiSchema(complexSchema) as Record; const unsupportedFields = [ "$schema", "$id", "$comment", "$ref", "$defs", "definitions", "additionalProperties", "patternProperties", "propertyNames", "unevaluatedProperties", "if", "then", "else", "not", "dependentRequired", "dependentSchemas", "contentMediaType", "contentEncoding", ]; for (const field of unsupportedFields) { expect(result).not.toHaveProperty(field); } expect(result.type).toBe("OBJECT"); expect(result.required).toEqual(["name"]); const props = result.properties as Record>; expect(props["name"]!.type).toBe("STRING"); expect(props["name"]).not.toHaveProperty("const"); expect(props["data"]!.type).toBe("ARRAY"); expect(props["data"]).not.toHaveProperty("minContains"); expect(props["data"]).not.toHaveProperty("maxContains"); }); }); describe("applyGeminiTransforms - full integration", () => { it("wraps tools in functionDeclarations after normalization", () => { const payload: RequestPayload = { contents: [], tools: [ { function: { name: "test_tool", description: "A test", input_schema: { type: "object", properties: { x: { type: "string" } } }, }, }, ], }; applyGeminiTransforms(payload, { model: "gemini-3-pro" }); const tools = payload.tools as Array>; expect(tools).toHaveLength(1); expect(tools[0]).toHaveProperty("functionDeclarations"); expect(tools[0]).not.toHaveProperty("function"); expect(tools[0]).not.toHaveProperty("parameters"); const decls = tools[0]!.functionDeclarations as Array>; expect(decls[0]!.name).toBe("test_tool"); const params = decls[0]!.parameters as Record; expect(params.type).toBe("OBJECT"); const props = params.properties as Record>; expect(props["x"]!.type).toBe("STRING"); }); it("handles mixed tools and googleSearch", () => { const payload: RequestPayload = { contents: [], tools: [ { name: "my_tool", parameters: { type: "object" } }, ], }; applyGeminiTransforms(payload, { model: "gemini-3-pro", googleSearch: { mode: "auto" }, }); const tools = payload.tools as Array>; expect(tools).toHaveLength(2); expect(tools[0]).toHaveProperty("functionDeclarations"); expect(tools[1]).toHaveProperty("googleSearch"); }); }); }); ================================================ FILE: src/plugin/transform/gemini.ts ================================================ /** * Gemini-specific Request Transformations * * Handles Gemini model-specific request transformations including: * - Thinking config (camelCase keys, thinkingLevel for Gemini 3) * - Tool normalization (function/custom format) * - Schema transformation (JSON Schema -> Gemini Schema format) */ import type { RequestPayload, ThinkingConfig, ThinkingTier, GoogleSearchConfig } from "./types"; /** * Transform a JSON Schema to Gemini-compatible format. * Based on @google/genai SDK's processJsonSchema() function. * * Key transformations: * - Converts type values to uppercase (object -> OBJECT) * - Removes unsupported fields like additionalProperties, $schema * - Recursively processes nested schemas (properties, items, anyOf, etc.) * * @param schema - A JSON Schema object or primitive value * @returns Gemini-compatible schema * * Fields that Gemini API rejects and must be removed from schemas. * Antigravity uses strict protobuf-backed JSON validation. */ const UNSUPPORTED_SCHEMA_FIELDS = new Set([ "additionalProperties", "$schema", "$id", "$comment", "$ref", "$defs", "definitions", "const", "contentMediaType", "contentEncoding", "if", "then", "else", "not", "patternProperties", "unevaluatedProperties", "unevaluatedItems", "dependentRequired", "dependentSchemas", "propertyNames", "minContains", "maxContains", ]); export function toGeminiSchema(schema: unknown): unknown { // Return primitives and arrays as-is if (!schema || typeof schema !== "object" || Array.isArray(schema)) { return schema; } const inputSchema = schema as Record; const result: Record = {}; // First pass: collect all property names for required validation const propertyNames = new Set(); if (inputSchema.properties && typeof inputSchema.properties === "object") { for (const propName of Object.keys(inputSchema.properties as Record)) { propertyNames.add(propName); } } for (const [key, value] of Object.entries(inputSchema)) { // Skip unsupported fields that Gemini API rejects if (UNSUPPORTED_SCHEMA_FIELDS.has(key)) { continue; } if (key === "type" && typeof value === "string") { // Convert type to uppercase for Gemini API result[key] = value.toUpperCase(); } else if (key === "properties" && typeof value === "object" && value !== null) { // Recursively transform nested property schemas const props: Record = {}; for (const [propName, propSchema] of Object.entries(value as Record)) { props[propName] = toGeminiSchema(propSchema); } result[key] = props; } else if (key === "items" && typeof value === "object") { // Transform array items schema result[key] = toGeminiSchema(value); } else if ((key === "anyOf" || key === "oneOf" || key === "allOf") && Array.isArray(value)) { // Transform union type schemas result[key] = value.map((item) => toGeminiSchema(item)); } else if (key === "enum" && Array.isArray(value)) { // Keep enum values as-is result[key] = value; } else if (key === "default" || key === "examples") { // Keep default and examples as-is result[key] = value; } else if (key === "required" && Array.isArray(value)) { // Filter required array to only include properties that exist // This fixes: "parameters.required[X]: property is not defined" if (propertyNames.size > 0) { const validRequired = value.filter((prop) => typeof prop === "string" && propertyNames.has(prop) ); if (validRequired.length > 0) { result[key] = validRequired; } // If no valid required properties, omit the required field entirely } else { // If there are no properties, keep required as-is (might be a schema without properties) result[key] = value; } } else { result[key] = value; } } // Issue #80: Ensure array schemas have an 'items' field // Gemini API requires: "parameters.properties[X].items: missing field" if (result.type === "ARRAY" && !result.items) { result.items = { type: "STRING" }; } return result; } /** * Check if a model is a Gemini model (not Claude). */ export function isGeminiModel(model: string): boolean { const lower = model.toLowerCase(); return lower.includes("gemini") && !lower.includes("claude"); } /** * Check if a model is Gemini 3 (uses thinkingLevel string). */ export function isGemini3Model(model: string): boolean { return model.toLowerCase().includes("gemini-3"); } /** * Check if a model is Gemini 2.5 (uses numeric thinkingBudget). */ export function isGemini25Model(model: string): boolean { return model.toLowerCase().includes("gemini-2.5"); } /** * Check if a model is an image generation model. * Image models don't support thinking and require imageConfig. */ export function isImageGenerationModel(model: string): boolean { const lower = model.toLowerCase(); return ( lower.includes("image") || lower.includes("imagen") ); } /** * Build Gemini 3 thinking config with thinkingLevel string. */ export function buildGemini3ThinkingConfig( includeThoughts: boolean, thinkingLevel: ThinkingTier, ): ThinkingConfig { return { includeThoughts, thinkingLevel, }; } /** * Build Gemini 2.5 thinking config with numeric thinkingBudget. */ export function buildGemini25ThinkingConfig( includeThoughts: boolean, thinkingBudget?: number, ): ThinkingConfig { return { includeThoughts, ...(typeof thinkingBudget === "number" && thinkingBudget > 0 ? { thinkingBudget } : {}), }; } /** * Image generation config for Gemini image models. * * Supported aspect ratios: "1:1", "2:3", "3:2", "3:4", "4:3", "4:5", "5:4", "9:16", "16:9", "21:9" */ export interface ImageConfig { aspectRatio?: string; } /** * Valid aspect ratios for image generation. */ const VALID_ASPECT_RATIOS = ["1:1", "2:3", "3:2", "3:4", "4:3", "4:5", "5:4", "9:16", "16:9", "21:9"]; /** * Build image generation config for Gemini image models. * * Configuration is read from environment variables: * - OPENCODE_IMAGE_ASPECT_RATIO: Aspect ratio (e.g., "16:9", "4:3") * * Defaults to 1:1 aspect ratio if not specified. * * Note: Resolution setting is not currently supported by the Antigravity API. */ export function buildImageGenerationConfig(): ImageConfig { // Read aspect ratio from environment or default to 1:1 const aspectRatio = process.env.OPENCODE_IMAGE_ASPECT_RATIO || "1:1"; if (VALID_ASPECT_RATIOS.includes(aspectRatio)) { return { aspectRatio }; } console.warn(`[gemini] Invalid aspect ratio "${aspectRatio}". Using default "1:1". Valid values: ${VALID_ASPECT_RATIOS.join(", ")}`); // Default to 1:1 square aspect ratio return { aspectRatio: "1:1" }; } /** * Normalize tools for Gemini models. * Ensures tools have proper function-style format. * * @returns Debug info about tool normalization */ export function normalizeGeminiTools( payload: RequestPayload, ): { toolDebugMissing: number; toolDebugSummaries: string[] } { let toolDebugMissing = 0; const toolDebugSummaries: string[] = []; if (!Array.isArray(payload.tools)) { return { toolDebugMissing, toolDebugSummaries }; } payload.tools = (payload.tools as unknown[]).map((tool: unknown, toolIndex: number) => { const t = tool as Record; // Skip normalization for Google Search tools (both old and new API) if (t.googleSearch || t.googleSearchRetrieval) { return t; } const newTool = { ...t }; const schemaCandidates = [ (newTool.function as Record | undefined)?.input_schema, (newTool.function as Record | undefined)?.parameters, (newTool.function as Record | undefined)?.inputSchema, (newTool.custom as Record | undefined)?.input_schema, (newTool.custom as Record | undefined)?.parameters, newTool.parameters, newTool.input_schema, newTool.inputSchema, ].filter(Boolean); const placeholderSchema: Record = { type: "OBJECT", properties: { _placeholder: { type: "BOOLEAN", description: "Placeholder. Always pass true.", }, }, required: ["_placeholder"], }; let schema = schemaCandidates[0] as Record | undefined; const schemaObjectOk = schema && typeof schema === "object" && !Array.isArray(schema); if (!schemaObjectOk) { schema = placeholderSchema; toolDebugMissing += 1; } else { // Transform existing schema to Gemini-compatible format schema = toGeminiSchema(schema) as Record; } const nameCandidate = newTool.name || (newTool.function as Record | undefined)?.name || (newTool.custom as Record | undefined)?.name || `tool-${toolIndex}`; // Always update function.input_schema with transformed schema if (newTool.function && schema) { (newTool.function as Record).input_schema = schema; } // Always update custom.input_schema with transformed schema if (newTool.custom && schema) { (newTool.custom as Record).input_schema = schema; } // Create custom from function if missing if (!newTool.custom && newTool.function) { const fn = newTool.function as Record; newTool.custom = { name: fn.name || nameCandidate, description: fn.description, input_schema: schema, }; } // Create custom if both missing if (!newTool.custom && !newTool.function) { newTool.custom = { name: nameCandidate, description: newTool.description, input_schema: schema, }; if (!newTool.parameters && !newTool.input_schema && !newTool.inputSchema) { newTool.parameters = schema; } } if (newTool.custom && !(newTool.custom as Record).input_schema) { (newTool.custom as Record).input_schema = { type: "OBJECT", properties: {}, }; toolDebugMissing += 1; } toolDebugSummaries.push( `idx=${toolIndex}, hasCustom=${!!newTool.custom}, customSchema=${!!(newTool.custom as Record | undefined)?.input_schema}, hasFunction=${!!newTool.function}, functionSchema=${!!(newTool.function as Record | undefined)?.input_schema}`, ); // Strip custom wrappers for Gemini; only function-style is accepted. if (newTool.custom) { delete newTool.custom; } return newTool; }); return { toolDebugMissing, toolDebugSummaries }; } /** * Apply all Gemini-specific transformations to a request payload. */ export interface GeminiTransformOptions { /** The effective model name (resolved) */ model: string; /** Tier-based thinking budget (from model suffix, for Gemini 2.5) */ tierThinkingBudget?: number; /** Tier-based thinking level (from model suffix, for Gemini 3) */ tierThinkingLevel?: ThinkingTier; /** Normalized thinking config from user settings */ normalizedThinking?: { includeThoughts?: boolean; thinkingBudget?: number }; /** Google Search configuration */ googleSearch?: GoogleSearchConfig; } export interface GeminiTransformResult { toolDebugMissing: number; toolDebugSummaries: string[]; /** Number of function declarations after wrapping */ wrappedFunctionCount: number; /** Number of passthrough tools (googleSearch, googleSearchRetrieval, codeExecution) */ passthroughToolCount: number; } /** * Apply all Gemini-specific transformations. */ export function applyGeminiTransforms( payload: RequestPayload, options: GeminiTransformOptions, ): GeminiTransformResult { const { model, tierThinkingBudget, tierThinkingLevel, normalizedThinking, googleSearch } = options; // 1. Apply thinking config if needed if (normalizedThinking) { let thinkingConfig: ThinkingConfig; if (tierThinkingLevel && isGemini3Model(model)) { // Gemini 3 uses thinkingLevel string thinkingConfig = buildGemini3ThinkingConfig( normalizedThinking.includeThoughts ?? true, tierThinkingLevel, ); } else { // Gemini 2.5 and others use numeric budget const thinkingBudget = tierThinkingBudget ?? normalizedThinking.thinkingBudget; thinkingConfig = buildGemini25ThinkingConfig( normalizedThinking.includeThoughts ?? true, thinkingBudget, ); } const generationConfig = (payload.generationConfig ?? {}) as Record; generationConfig.thinkingConfig = thinkingConfig; payload.generationConfig = generationConfig; } // 2. Apply Google Search (Grounding) if enabled // Uses the new googleSearch API for Gemini 2.0+ / Gemini 3 models // Note: The old googleSearchRetrieval with dynamicRetrievalConfig is deprecated // The new API doesn't support threshold - the model decides when to search automatically if (googleSearch && googleSearch.mode === 'auto') { const tools = (payload.tools as unknown[]) || []; if (!payload.tools) { payload.tools = tools; } // Add Google Search tool using new API format for Gemini 2.0+ // See: https://ai.google.dev/gemini-api/docs/grounding (payload.tools as any[]).push({ googleSearch: {}, }); } // 3. Normalize tools const result = normalizeGeminiTools(payload); // 4. Wrap tools in functionDeclarations format (fixes #203, #206) // Antigravity strict protobuf validation rejects wrapper-level 'parameters' field // Must be: [{ functionDeclarations: [{ name, description, parameters }] }] const wrapResult = wrapToolsAsFunctionDeclarations(payload); return { ...result, wrappedFunctionCount: wrapResult.wrappedFunctionCount, passthroughToolCount: wrapResult.passthroughToolCount, }; } export interface WrapToolsResult { wrappedFunctionCount: number; passthroughToolCount: number; } /** * Wrap tools array in Gemini's required functionDeclarations format. * * Gemini/Antigravity API expects: * { tools: [{ functionDeclarations: [{ name, description, parameters }] }] } * * NOT: * { tools: [{ function: {...}, parameters: {...} }] } * * The wrapper-level 'parameters' field causes: * "Unknown name 'parameters' at 'request.tools[0]'" */ /** * Detect if a tool is a web search tool in any of the supported formats: * - Claude/Anthropic: { type: "web_search_20250305" } or { name: "web_search" } * - Gemini native: { googleSearch: {} } or { googleSearchRetrieval: {} } */ function isWebSearchTool(tool: Record): boolean { // 1. Gemini native format if (tool.googleSearch || tool.googleSearchRetrieval) { return true; } // 2. Claude/Anthropic format: { type: "web_search_20250305" } if (tool.type === "web_search_20250305") { return true; } // 3. Simple name-based format: { name: "web_search" | "google_search" } const name = tool.name as string | undefined; if (name === "web_search" || name === "google_search") { return true; } return false; } export function wrapToolsAsFunctionDeclarations(payload: RequestPayload): WrapToolsResult { if (!Array.isArray(payload.tools) || payload.tools.length === 0) { return { wrappedFunctionCount: 0, passthroughToolCount: 0 }; } const functionDeclarations: Array<{ name: string; description: string; parameters: Record; }> = []; const passthroughTools: unknown[] = []; let hasWebSearchTool = false; for (const tool of payload.tools as Array>) { // Handle passthrough tools (Google Search and Code Execution) if (tool.googleSearch || tool.googleSearchRetrieval || tool.codeExecution) { passthroughTools.push(tool); continue; } // Detect and convert web search tools to Gemini format if (isWebSearchTool(tool)) { hasWebSearchTool = true; continue; // Will be added as { googleSearch: {} } at the end } if (tool.functionDeclarations) { if (Array.isArray(tool.functionDeclarations)) { for (const decl of tool.functionDeclarations as Array>) { functionDeclarations.push({ name: String(decl.name || `tool-${functionDeclarations.length}`), description: String(decl.description || ""), parameters: (decl.parameters as Record) || { type: "OBJECT", properties: {} }, }); } } continue; } const fn = tool.function as Record | undefined; const custom = tool.custom as Record | undefined; const name = String( tool.name || fn?.name || custom?.name || `tool-${functionDeclarations.length}` ); const description = String( tool.description || fn?.description || custom?.description || "" ); const schema = ( fn?.input_schema || fn?.parameters || fn?.inputSchema || custom?.input_schema || custom?.parameters || tool.parameters || tool.input_schema || tool.inputSchema || { type: "OBJECT", properties: {} } ) as Record; functionDeclarations.push({ name, description, parameters: schema, }); } const finalTools: unknown[] = []; if (functionDeclarations.length > 0) { finalTools.push({ functionDeclarations }); } finalTools.push(...passthroughTools); // Add googleSearch tool if a web search tool was detected // Note: googleSearch cannot be combined with functionDeclarations in the same request // If there are function declarations, we skip adding googleSearch (Gemini API limitation) if (hasWebSearchTool && functionDeclarations.length === 0) { finalTools.push({ googleSearch: {} }); } else if (hasWebSearchTool && functionDeclarations.length > 0) { // Log warning: web search requested but can't be used with functions console.warn( "[gemini] web_search tool detected but cannot be combined with function declarations. " + "Use the explicit google_search() tool call instead." ); } payload.tools = finalTools; return { wrappedFunctionCount: functionDeclarations.length, passthroughToolCount: passthroughTools.length + (hasWebSearchTool && functionDeclarations.length === 0 ? 1 : 0), }; } ================================================ FILE: src/plugin/transform/index.ts ================================================ /** * Transform Module Index * * Re-exports transform functions and types for request transformation. */ // Types export type { ModelFamily, ThinkingTier, TransformContext, TransformResult, TransformDebugInfo, RequestPayload, ThinkingConfig, ResolvedModel, GoogleSearchConfig, } from "./types"; // Model resolution export { resolveModelWithTier, resolveModelWithVariant, resolveModelForHeaderStyle, getModelFamily, MODEL_ALIASES, THINKING_TIER_BUDGETS, GEMINI_3_THINKING_LEVELS, } from "./model-resolver"; export type { VariantConfig } from "./model-resolver"; // Claude transforms export { isClaudeModel, isClaudeThinkingModel, configureClaudeToolConfig, buildClaudeThinkingConfig, ensureClaudeMaxOutputTokens, appendClaudeThinkingHint, normalizeClaudeTools, applyClaudeTransforms, CLAUDE_THINKING_MAX_OUTPUT_TOKENS, CLAUDE_INTERLEAVED_THINKING_HINT, } from "./claude"; export type { ClaudeTransformOptions, ClaudeTransformResult } from "./claude"; // Gemini transforms export { isGeminiModel, isGemini3Model, isGemini25Model, isImageGenerationModel, buildGemini3ThinkingConfig, buildGemini25ThinkingConfig, buildImageGenerationConfig, normalizeGeminiTools, applyGeminiTransforms, } from "./gemini"; export type { GeminiTransformOptions, GeminiTransformResult, ImageConfig } from "./gemini"; // Cross-model sanitization export { sanitizeCrossModelPayload, sanitizeCrossModelPayloadInPlace, getModelFamily as getCrossModelFamily, stripGeminiThinkingMetadata, stripClaudeThinkingFields, } from "./cross-model-sanitizer"; export type { SanitizerOptions } from "./cross-model-sanitizer"; ================================================ FILE: src/plugin/transform/model-resolver.test.ts ================================================ import { describe, it, expect } from "vitest"; import { resolveModelWithTier, resolveModelWithVariant, resolveModelForHeaderStyle } from "./model-resolver"; describe("resolveModelWithTier", () => { describe("Gemini 3 flash models (Issue #109)", () => { it("antigravity-gemini-3-flash gets default thinkingLevel 'low'", () => { const result = resolveModelWithTier("antigravity-gemini-3-flash"); expect(result.actualModel).toBe("gemini-3-flash"); expect(result.thinkingLevel).toBe("low"); expect(result.quotaPreference).toBe("antigravity"); }); it("gemini-3-flash gets default thinkingLevel 'low'", () => { const result = resolveModelWithTier("gemini-3-flash"); expect(result.actualModel).toBe("gemini-3-flash"); expect(result.thinkingLevel).toBe("low"); expect(result.quotaPreference).toBe("antigravity"); }); it("gemini-3-flash-preview gets default thinkingLevel 'low' with antigravity quota", () => { const result = resolveModelWithTier("gemini-3-flash-preview"); expect(result.actualModel).toBe("gemini-3-flash-preview"); expect(result.thinkingLevel).toBe("low"); // All Gemini models now default to antigravity expect(result.quotaPreference).toBe("antigravity"); }); }); describe("Gemini 3 preview models (Issue #115)", () => { it("gemini-3-pro-preview gets default thinkingLevel 'low' with antigravity quota", () => { const result = resolveModelWithTier("gemini-3-pro-preview"); expect(result.actualModel).toBe("gemini-3-pro-preview"); expect(result.thinkingLevel).toBe("low"); // All Gemini models now default to antigravity expect(result.quotaPreference).toBe("antigravity"); }); it("gemini-3.1-pro-preview gets default thinkingLevel 'low' with antigravity quota", () => { const result = resolveModelWithTier("gemini-3.1-pro-preview"); expect(result.actualModel).toBe("gemini-3.1-pro-preview"); expect(result.thinkingLevel).toBe("low"); expect(result.quotaPreference).toBe("antigravity"); }); }); describe("All Gemini models default to antigravity quota", () => { it("gemini-2.5-flash defaults to antigravity", () => { const result = resolveModelWithTier("gemini-2.5-flash"); expect(result.quotaPreference).toBe("antigravity"); }); it("gemini-2.5-pro defaults to antigravity", () => { const result = resolveModelWithTier("gemini-2.5-pro"); expect(result.quotaPreference).toBe("antigravity"); }); it("gemini-2.0-flash defaults to antigravity", () => { const result = resolveModelWithTier("gemini-2.0-flash"); expect(result.quotaPreference).toBe("antigravity"); }); }); describe("cli_first quota preference", () => { it("prefers gemini-cli when cli_first is true and no prefix is set", () => { const result = resolveModelWithTier("gemini-3-flash", { cli_first: true }); expect(result.quotaPreference).toBe("gemini-cli"); expect(result.explicitQuota).toBe(false); }); it("keeps antigravity when antigravity prefix is explicit", () => { const result = resolveModelWithTier("antigravity-gemini-3-flash", { cli_first: true }); expect(result.quotaPreference).toBe("antigravity"); expect(result.explicitQuota).toBe(true); }); it("keeps antigravity for Claude models when cli_first is true", () => { const result = resolveModelWithTier("claude-opus-4-6-thinking", { cli_first: true }); expect(result.quotaPreference).toBe("antigravity"); }); it("keeps antigravity for image models when cli_first is true", () => { const result = resolveModelWithTier("gemini-3-pro-image", { cli_first: true }); expect(result.quotaPreference).toBe("antigravity"); expect(result.explicitQuota).toBe(true); }); it("defaults to antigravity when cli_first is false", () => { const result = resolveModelWithTier("gemini-3-flash", { cli_first: false }); expect(result.quotaPreference).toBe("antigravity"); }); }); describe("Antigravity Gemini 3 with tier suffix", () => { it("antigravity-gemini-3-pro-low gets thinkingLevel from tier", () => { const result = resolveModelWithTier("antigravity-gemini-3-pro-low"); expect(result.actualModel).toBe("gemini-3-pro-low"); expect(result.thinkingLevel).toBe("low"); expect(result.quotaPreference).toBe("antigravity"); }); it("antigravity-gemini-3-pro-high gets thinkingLevel from tier", () => { const result = resolveModelWithTier("antigravity-gemini-3-pro-high"); expect(result.actualModel).toBe("gemini-3-pro-high"); expect(result.thinkingLevel).toBe("high"); expect(result.quotaPreference).toBe("antigravity"); }); it("antigravity-gemini-3-flash-medium gets thinkingLevel from tier", () => { const result = resolveModelWithTier("antigravity-gemini-3-flash-medium"); expect(result.actualModel).toBe("gemini-3-flash"); expect(result.thinkingLevel).toBe("medium"); }); it("antigravity-gemini-3.1-pro gets default -low model", () => { const result = resolveModelWithTier("antigravity-gemini-3.1-pro"); expect(result.actualModel).toBe("gemini-3.1-pro-low"); expect(result.thinkingLevel).toBe("low"); }); }); describe("Claude thinking models default budget", () => { it("antigravity-claude-opus-4-6-thinking gets default max budget (32768)", () => { const result = resolveModelWithTier("antigravity-claude-opus-4-6-thinking"); expect(result.actualModel).toBe("claude-opus-4-6-thinking"); expect(result.thinkingBudget).toBe(32768); expect(result.isThinkingModel).toBe(true); expect(result.quotaPreference).toBe("antigravity"); }); }); describe("Claude Sonnet 4.6 (non-thinking)", () => { it("claude-sonnet-4-6 resolves as non-thinking model", () => { const result = resolveModelWithTier("claude-sonnet-4-6"); expect(result.actualModel).toBe("claude-sonnet-4-6"); expect(result.isThinkingModel).toBe(false); expect(result.thinkingBudget).toBeUndefined(); expect(result.quotaPreference).toBe("antigravity"); }); it("antigravity-claude-sonnet-4-6 resolves as non-thinking model with explicit quota", () => { const result = resolveModelWithTier("antigravity-claude-sonnet-4-6"); expect(result.actualModel).toBe("claude-sonnet-4-6"); expect(result.isThinkingModel).toBe(false); expect(result.thinkingBudget).toBeUndefined(); expect(result.quotaPreference).toBe("antigravity"); expect(result.explicitQuota).toBe(true); }); it("gemini-claude-sonnet-4-6 alias resolves to claude-sonnet-4-6", () => { const result = resolveModelWithTier("gemini-claude-sonnet-4-6"); expect(result.actualModel).toBe("claude-sonnet-4-6"); expect(result.isThinkingModel).toBe(false); expect(result.quotaPreference).toBe("antigravity"); }); }); describe("Image models", () => { it("marks antigravity-gemini-3-pro-image as explicit quota", () => { const result = resolveModelWithTier("antigravity-gemini-3-pro-image"); expect(result.actualModel).toBe("gemini-3-pro-image"); expect(result.isImageModel).toBe(true); expect(result.explicitQuota).toBe(true); expect(result.quotaPreference).toBe("antigravity"); }); it("marks gemini-3-pro-image as explicit quota", () => { const result = resolveModelWithTier("gemini-3-pro-image"); expect(result.actualModel).toBe("gemini-3-pro-image"); expect(result.isImageModel).toBe(true); expect(result.explicitQuota).toBe(true); expect(result.quotaPreference).toBe("antigravity"); }); }); }); describe("resolveModelWithVariant", () => { describe("without variant config", () => { it("falls back to tier resolution for Claude thinking models", () => { const result = resolveModelWithVariant("claude-opus-4-6-thinking-low"); expect(result.actualModel).toBe("claude-opus-4-6-thinking"); expect(result.thinkingBudget).toBe(8192); expect(result.configSource).toBeUndefined(); }); it("falls back to tier resolution for Gemini 3 models", () => { const result = resolveModelWithVariant("gemini-3-pro-high"); expect(result.actualModel).toBe("gemini-3-pro"); expect(result.thinkingLevel).toBe("high"); expect(result.configSource).toBeUndefined(); }); }); describe("with variant config", () => { it("overrides tier budget for Claude models", () => { const result = resolveModelWithVariant("antigravity-claude-opus-4-6-thinking", { thinkingBudget: 24000, }); expect(result.actualModel).toBe("claude-opus-4-6-thinking"); expect(result.thinkingBudget).toBe(24000); expect(result.configSource).toBe("variant"); }); it("maps budget to thinkingLevel for Gemini 3 - low", () => { const result = resolveModelWithVariant("antigravity-gemini-3-pro", { thinkingBudget: 8000, }); expect(result.actualModel).toBe("gemini-3-pro-low"); expect(result.thinkingLevel).toBe("low"); expect(result.thinkingBudget).toBeUndefined(); expect(result.configSource).toBe("variant"); }); it("maps budget to thinkingLevel for Gemini 3 Flash - medium (no tier suffix)", () => { const result = resolveModelWithVariant("antigravity-gemini-3-flash", { thinkingBudget: 12000, }); expect(result.actualModel).toBe("gemini-3-flash"); expect(result.thinkingLevel).toBe("medium"); expect(result.configSource).toBe("variant"); }); it("maps budget to thinkingLevel for Gemini 3 - high", () => { const result = resolveModelWithVariant("antigravity-gemini-3-pro", { thinkingBudget: 32000, }); expect(result.thinkingLevel).toBe("high"); expect(result.configSource).toBe("variant"); }); it("uses budget directly for non-Gemini 3 models", () => { const result = resolveModelWithVariant("gemini-2.5-pro", { thinkingBudget: 20000, }); expect(result.thinkingBudget).toBe(20000); expect(result.thinkingLevel).toBeUndefined(); expect(result.configSource).toBe("variant"); }); }); describe("backward compatibility", () => { it("tier-suffixed models work without variant config", () => { const lowResult = resolveModelWithVariant("claude-opus-4-6-thinking-low"); expect(lowResult.thinkingBudget).toBe(8192); const medResult = resolveModelWithVariant("claude-opus-4-6-thinking-medium"); expect(medResult.thinkingBudget).toBe(16384); const highResult = resolveModelWithVariant("claude-opus-4-6-thinking-high"); expect(highResult.thinkingBudget).toBe(32768); }); it("variant config overrides tier suffix", () => { const result = resolveModelWithVariant("claude-opus-4-6-thinking-low", { thinkingBudget: 50000, }); expect(result.thinkingBudget).toBe(50000); expect(result.configSource).toBe("variant"); }); }); }); describe("Issue #103: resolveModelForHeaderStyle", () => { describe("quota fallback from gemini-cli to antigravity", () => { it("transforms gemini-3-flash-preview to gemini-3-flash for antigravity", () => { const result = resolveModelForHeaderStyle("gemini-3-flash-preview", "antigravity"); expect(result.actualModel).toBe("gemini-3-flash"); expect(result.quotaPreference).toBe("antigravity"); }); it("transforms gemini-3-pro-preview to gemini-3-pro-low for antigravity", () => { const result = resolveModelForHeaderStyle("gemini-3-pro-preview", "antigravity"); expect(result.actualModel).toBe("gemini-3-pro-low"); expect(result.quotaPreference).toBe("antigravity"); }); it("transforms gemini-3.1-pro-preview to gemini-3.1-pro-low for antigravity", () => { const result = resolveModelForHeaderStyle("gemini-3.1-pro-preview", "antigravity"); expect(result.actualModel).toBe("gemini-3.1-pro-low"); expect(result.quotaPreference).toBe("antigravity"); }); it("transforms gemini-3.1-pro-preview-customtools to gemini-3.1-pro-low for antigravity", () => { const result = resolveModelForHeaderStyle("gemini-3.1-pro-preview-customtools", "antigravity"); expect(result.actualModel).toBe("gemini-3.1-pro-low"); expect(result.quotaPreference).toBe("antigravity"); }); }); describe("quota fallback from antigravity to gemini-cli", () => { it("transforms gemini-3-flash to gemini-3-flash-preview for gemini-cli", () => { const result = resolveModelForHeaderStyle("gemini-3-flash", "gemini-cli"); expect(result.actualModel).toBe("gemini-3-flash-preview"); expect(result.quotaPreference).toBe("gemini-cli"); }); it("transforms gemini-3-pro-low to gemini-3-pro-preview for gemini-cli", () => { const result = resolveModelForHeaderStyle("gemini-3-pro-low", "gemini-cli"); expect(result.actualModel).toBe("gemini-3-pro-preview"); expect(result.quotaPreference).toBe("gemini-cli"); }); it("transforms gemini-3.1-pro-low to gemini-3.1-pro-preview for gemini-cli", () => { const result = resolveModelForHeaderStyle("gemini-3.1-pro-low", "gemini-cli"); expect(result.actualModel).toBe("gemini-3.1-pro-preview"); expect(result.quotaPreference).toBe("gemini-cli"); }); it("keeps gemini-3.1-pro-preview-customtools unchanged for gemini-cli", () => { const result = resolveModelForHeaderStyle("gemini-3.1-pro-preview-customtools", "gemini-cli"); expect(result.actualModel).toBe("gemini-3.1-pro-preview-customtools"); expect(result.quotaPreference).toBe("gemini-cli"); }); }); describe("no transformation needed", () => { it("keeps gemini-2.5-flash unchanged for both header styles", () => { const antigravity = resolveModelForHeaderStyle("gemini-2.5-flash", "antigravity"); const cli = resolveModelForHeaderStyle("gemini-2.5-flash", "gemini-cli"); expect(antigravity.actualModel).toBe("gemini-2.5-flash"); expect(cli.actualModel).toBe("gemini-2.5-flash"); }); it("keeps claude models unchanged (antigravity only)", () => { const result = resolveModelForHeaderStyle("claude-opus-4-6-thinking", "antigravity"); expect(result.actualModel).toBe("claude-opus-4-6-thinking"); }); }); }); ================================================ FILE: src/plugin/transform/model-resolver.ts ================================================ /** * Model Resolution with Thinking Tier Support * * Resolves model names with tier suffixes (e.g., gemini-3-pro-high, claude-opus-4-6-thinking-low) * to their actual API model names and corresponding thinking configurations. */ import type { ResolvedModel, ThinkingTier, GoogleSearchConfig } from "./types"; export interface ModelResolverOptions { cli_first?: boolean; } /** * Thinking tier budgets by model family. * Claude and Gemini 2.5 Pro use numeric budgets. */ export const THINKING_TIER_BUDGETS = { claude: { low: 8192, medium: 16384, high: 32768 }, "gemini-2.5-pro": { low: 8192, medium: 16384, high: 32768 }, "gemini-2.5-flash": { low: 6144, medium: 12288, high: 24576 }, default: { low: 4096, medium: 8192, high: 16384 }, } as const; /** * Gemini 3 uses thinkingLevel strings instead of numeric budgets. * Flash supports: minimal, low, medium, high * Pro supports: low, high (no minimal/medium) */ export const GEMINI_3_THINKING_LEVELS = ["minimal", "low", "medium", "high"] as const; /** * Model aliases - maps user-friendly names to API model names. * * Format: * - Gemini 3 Pro variants: gemini-3-pro-{low,medium,high} * - Claude thinking variants: claude-{model}-thinking-{low,medium,high} * - Claude non-thinking: claude-{model} (no -thinking suffix) */ export const MODEL_ALIASES: Record = { // Gemini 3 variants - for Gemini CLI only (tier stripped, thinkingLevel used) // For Antigravity, these are bypassed and full model name is kept "gemini-3-pro-low": "gemini-3-pro", "gemini-3-pro-high": "gemini-3-pro", "gemini-3.1-pro-low": "gemini-3.1-pro", "gemini-3.1-pro-high": "gemini-3.1-pro", "gemini-3-flash-low": "gemini-3-flash", "gemini-3-flash-medium": "gemini-3-flash", "gemini-3-flash-high": "gemini-3-flash", // Claude proxy names (gemini- prefix for compatibility) "gemini-claude-opus-4-6-thinking-low": "claude-opus-4-6-thinking", "gemini-claude-opus-4-6-thinking-medium": "claude-opus-4-6-thinking", "gemini-claude-opus-4-6-thinking-high": "claude-opus-4-6-thinking", "gemini-claude-sonnet-4-6": "claude-sonnet-4-6", // Image generation models - only gemini-3-pro-image is available via Antigravity API // Note: gemini-2.5-flash-image (Nano Banana) is NOT supported by Antigravity - only Google AI API // Reference: Antigravity-Manager/src-tauri/src/proxy/common/model_mapping.rs }; const TIER_REGEX = /-(minimal|low|medium|high)$/; const QUOTA_PREFIX_REGEX = /^antigravity-/i; const GEMINI_3_PRO_REGEX = /^gemini-3(?:\.\d+)?-pro/i; const GEMINI_3_FLASH_REGEX = /^gemini-3(?:\.\d+)?-flash/i; // ANTIGRAVITY_ONLY_MODELS removed - all models now default to antigravity /** * Image generation models - always route to Antigravity. * These models don't support thinking and require imageConfig. */ const IMAGE_GENERATION_MODELS = /image|imagen/i; // Legacy LEGACY_ANTIGRAVITY_GEMINI3 regex removed - all Gemini models now default to antigravity /** * Models that support thinking tier suffixes. * Only these models should have -low/-medium/-high stripped as thinking tiers. * GPT models like gpt-oss-120b-medium should NOT have -medium stripped. */ function supportsThinkingTiers(model: string): boolean { const lower = model.toLowerCase(); return ( lower.includes("gemini-3") || lower.includes("gemini-2.5") || (lower.includes("claude") && lower.includes("thinking")) ); } /** * Extracts thinking tier from model name suffix. * Only extracts tier for models that support thinking tiers. */ function extractThinkingTierFromModel(model: string): ThinkingTier | undefined { // Only extract tier for models that support thinking tiers if (!supportsThinkingTiers(model)) { return undefined; } const tierMatch = model.match(TIER_REGEX); return tierMatch?.[1] as ThinkingTier | undefined; } /** * Determines the budget family for a model. */ function getBudgetFamily(model: string): keyof typeof THINKING_TIER_BUDGETS { if (model.includes("claude")) { return "claude"; } if (model.includes("gemini-2.5-pro")) { return "gemini-2.5-pro"; } if (model.includes("gemini-2.5-flash")) { return "gemini-2.5-flash"; } return "default"; } /** * Checks if a model is a thinking-capable model. */ function isThinkingCapableModel(model: string): boolean { const lower = model.toLowerCase(); return ( lower.includes("thinking") || lower.includes("gemini-3") || lower.includes("gemini-2.5") ); } function isGemini3ProModel(model: string): boolean { return GEMINI_3_PRO_REGEX.test(model); } function isGemini3FlashModel(model: string): boolean { return GEMINI_3_FLASH_REGEX.test(model); } /** * Resolves a model name with optional tier suffix and quota prefix to its actual API model name * and corresponding thinking configuration. * * Quota routing: * - Default to Antigravity quota unless cli_first is enabled for Gemini models * - Fallback to Gemini CLI happens at account rotation level when Antigravity is exhausted * - "antigravity-" prefix marks explicit quota (no fallback allowed) * - Claude and image models always use Antigravity * * Examples: * - "gemini-2.5-flash" → { quotaPreference: "antigravity" } * - "gemini-3-pro-preview" → { quotaPreference: "antigravity" } * - "antigravity-gemini-3-pro-high" → { quotaPreference: "antigravity", explicitQuota: true } * - "claude-opus-4-6-thinking-medium" → { quotaPreference: "antigravity" } * * @param requestedModel - The model name from the request * @param options - Optional configuration including cli_first preference * @returns Resolved model with thinking configuration */ export function resolveModelWithTier(requestedModel: string, options: ModelResolverOptions = {}): ResolvedModel { const isAntigravity = QUOTA_PREFIX_REGEX.test(requestedModel); const modelWithoutQuota = requestedModel.replace(QUOTA_PREFIX_REGEX, ""); const tier = extractThinkingTierFromModel(modelWithoutQuota); const baseName = tier ? modelWithoutQuota.replace(TIER_REGEX, "") : modelWithoutQuota; const isImageModel = IMAGE_GENERATION_MODELS.test(modelWithoutQuota); const isClaudeModel = modelWithoutQuota.toLowerCase().includes("claude"); // All models default to Antigravity quota unless cli_first is enabled // Fallback to gemini-cli happens at the account rotation level when Antigravity is exhausted const preferGeminiCli = options.cli_first === true && !isAntigravity && !isImageModel && !isClaudeModel; const quotaPreference = preferGeminiCli ? "gemini-cli" as const : "antigravity" as const; const explicitQuota = isAntigravity || isImageModel; const isGemini3 = modelWithoutQuota.toLowerCase().startsWith("gemini-3"); const skipAlias = isAntigravity && isGemini3; // For Antigravity Gemini 3 Pro models without explicit tier, append default tier // Antigravity API: gemini-3-pro requires tier suffix (gemini-3-pro-low/high) // gemini-3-flash uses bare name + thinkingLevel param // Pro defaults to -low unless an explicit tier is provided const isGemini3Pro = isGemini3ProModel(modelWithoutQuota); const isGemini3Flash = isGemini3FlashModel(modelWithoutQuota); let antigravityModel = modelWithoutQuota; if (skipAlias) { if (isGemini3Pro && !tier && !isImageModel) { antigravityModel = `${modelWithoutQuota}-low`; } else if (isGemini3Flash && tier) { antigravityModel = baseName; } } const actualModel = skipAlias ? antigravityModel : MODEL_ALIASES[modelWithoutQuota] || MODEL_ALIASES[baseName] || baseName; const resolvedModel = actualModel; const isThinking = isThinkingCapableModel(resolvedModel); // Image generation models don't support thinking - return early without thinking config if (isImageModel) { return { actualModel: resolvedModel, isThinkingModel: false, isImageModel: true, quotaPreference, explicitQuota, }; } // Check if this is a Gemini 3 model (works for both aliased and skipAlias paths) const isEffectiveGemini3 = resolvedModel.toLowerCase().includes("gemini-3"); const isClaudeThinking = resolvedModel.toLowerCase().includes("claude") && resolvedModel.toLowerCase().includes("thinking"); if (!tier) { // Gemini 3 models without explicit tier get a default thinkingLevel if (isEffectiveGemini3) { return { actualModel: resolvedModel, thinkingLevel: "low", isThinkingModel: true, quotaPreference, explicitQuota, }; } // Claude thinking models without explicit tier get max budget (32768) // Per Anthropic docs, budget_tokens is required when enabling extended thinking if (isClaudeThinking) { return { actualModel: resolvedModel, thinkingBudget: THINKING_TIER_BUDGETS.claude.high, isThinkingModel: true, quotaPreference, explicitQuota, }; } return { actualModel: resolvedModel, isThinkingModel: isThinking, quotaPreference, explicitQuota }; } // Gemini 3 models with tier always get thinkingLevel set if (isEffectiveGemini3) { return { actualModel: resolvedModel, thinkingLevel: tier, tier, isThinkingModel: true, quotaPreference, explicitQuota, }; } const budgetFamily = getBudgetFamily(resolvedModel); const budgets = THINKING_TIER_BUDGETS[budgetFamily]; const thinkingBudget = budgets[tier]; return { actualModel: resolvedModel, thinkingBudget, tier, isThinkingModel: isThinking, quotaPreference, explicitQuota, }; } /** * Gets the model family for routing decisions. */ export function getModelFamily(model: string): "claude" | "gemini-flash" | "gemini-pro" { const lower = model.toLowerCase(); if (lower.includes("claude")) { return "claude"; } if (lower.includes("flash")) { return "gemini-flash"; } return "gemini-pro"; } /** * Variant config from OpenCode's providerOptions. */ export interface VariantConfig { thinkingBudget?: number; googleSearch?: GoogleSearchConfig; } /** * Maps a thinking budget to Gemini 3 thinking level. * ≤8192 → low, ≤16384 → medium, >16384 → high */ function budgetToGemini3Level(budget: number): "low" | "medium" | "high" { if (budget <= 8192) return "low"; if (budget <= 16384) return "medium"; return "high"; } /** * Resolves model name for a specific headerStyle (quota fallback support). * Transforms model names when switching between gemini-cli and antigravity quotas. * * Issue #103: When quota fallback occurs, model names need to be transformed: * - gemini-3-flash-preview (gemini-cli) → gemini-3-flash (antigravity) * - gemini-3-pro-preview (gemini-cli) → gemini-3-pro-low (antigravity) * - gemini-3-flash (antigravity) → gemini-3-flash-preview (gemini-cli) */ export function resolveModelForHeaderStyle( requestedModel: string, headerStyle: "antigravity" | "gemini-cli" ): ResolvedModel { const lower = requestedModel.toLowerCase(); const isGemini3 = lower.includes("gemini-3"); if (!isGemini3) { return resolveModelWithTier(requestedModel); } if (headerStyle === "antigravity") { let transformedModel = requestedModel .replace(/-preview-customtools$/i, "") .replace(/-preview$/i, "") .replace(/^antigravity-/i, ""); const isGemini3Pro = isGemini3ProModel(transformedModel); const hasTierSuffix = /-(low|medium|high)$/i.test(transformedModel); const isImageModel = IMAGE_GENERATION_MODELS.test(transformedModel); // Don't add tier suffix to image models - they don't support thinking if (isGemini3Pro && !hasTierSuffix && !isImageModel) { transformedModel = `${transformedModel}-low`; } const prefixedModel = `antigravity-${transformedModel}`; return resolveModelWithTier(prefixedModel); } if (headerStyle === "gemini-cli") { let transformedModel = requestedModel .replace(/^antigravity-/i, "") .replace(/-(low|medium|high)$/i, ""); const hasPreviewSuffix = /-preview($|-)/i.test(transformedModel); if (!hasPreviewSuffix) { transformedModel = `${transformedModel}-preview`; } return { ...resolveModelWithTier(transformedModel), quotaPreference: "gemini-cli", }; } return resolveModelWithTier(requestedModel); } /** * Resolves model with variant config from providerOptions. * Variant config takes priority over tier suffix in model name. */ export function resolveModelWithVariant( requestedModel: string, variantConfig?: VariantConfig ): ResolvedModel { const base = resolveModelWithTier(requestedModel); if (!variantConfig) { return base; } // Apply Google Search config if present if (variantConfig.googleSearch) { base.googleSearch = variantConfig.googleSearch; base.configSource = "variant"; } if (!variantConfig.thinkingBudget) { return base; } const budget = variantConfig.thinkingBudget; const isGemini3 = base.actualModel.toLowerCase().includes("gemini-3"); if (isGemini3) { const level = budgetToGemini3Level(budget); const isAntigravityGemini3Pro = base.quotaPreference === "antigravity" && isGemini3ProModel(base.actualModel); let actualModel = base.actualModel; if (isAntigravityGemini3Pro) { const baseModel = base.actualModel.replace(/-(low|medium|high)$/, ""); actualModel = `${baseModel}-${level}`; } return { ...base, actualModel, thinkingLevel: level, thinkingBudget: undefined, configSource: "variant", }; } return { ...base, thinkingBudget: budget, configSource: "variant", }; } ================================================ FILE: src/plugin/transform/types.ts ================================================ import type { HeaderStyle } from "../../constants"; export type ModelFamily = "claude" | "gemini-flash" | "gemini-pro"; export type ThinkingTier = "low" | "medium" | "high"; /** * Context for request transformation. * Contains all information needed to transform a request payload. */ export interface TransformContext { /** The resolved project ID for the API call */ projectId: string; /** The resolved model name (after alias resolution) */ model: string; /** The original model name from the request */ requestedModel: string; /** Model family for routing decisions */ family: ModelFamily; /** Whether this is a streaming request */ streaming: boolean; /** Unique request ID for tracking */ requestId: string; /** Session ID for signature caching */ sessionId?: string; /** Thinking tier if specified via model suffix */ thinkingTier?: ThinkingTier; /** Thinking budget for Claude models (derived from tier) */ thinkingBudget?: number; /** Thinking level for Gemini 3 models (derived from tier) */ thinkingLevel?: string; } /** * Result of request transformation. */ export interface TransformResult { /** The transformed request body as JSON string */ body: string; /** Debug information about the transformation */ debugInfo: TransformDebugInfo; } /** * Debug information from transformation. */ export interface TransformDebugInfo { /** Which transformer was used */ transformer: "claude" | "gemini"; /** Number of tools in the request */ toolCount: number; /** Whether tools were transformed */ toolsTransformed?: boolean; /** Thinking tier if resolved */ thinkingTier?: string; /** Thinking budget if set */ thinkingBudget?: number; /** Thinking level if set (Gemini 3) */ thinkingLevel?: string; } /** * Generic request payload type. * The actual structure varies between Claude and Gemini. */ export type RequestPayload = Record; /** * Thinking configuration normalized from various input formats. */ export interface ThinkingConfig { /** Numeric thinking budget (for Claude and Gemini 2.5) */ thinkingBudget?: number; /** String thinking level (for Gemini 3: 'low', 'medium', 'high') */ thinkingLevel?: string; /** Whether to include thinking in the response */ includeThoughts?: boolean; /** Snake_case variant for Antigravity backend */ include_thoughts?: boolean; } /** * Google Search Grounding configuration. * * Note: The new googleSearch API for Gemini 2.0+ does not support threshold * configuration. The model automatically decides when to search. * The threshold field is kept for backward compatibility but is ignored. */ export interface GoogleSearchConfig { mode?: 'auto' | 'off'; /** @deprecated No longer used - kept for backward compatibility */ threshold?: number; } /** * Model resolution result with tier information. */ export interface ResolvedModel { /** The actual model name for the API call */ actualModel: string; /** Thinking level for Gemini 3 models */ thinkingLevel?: string; /** Thinking budget for Claude/Gemini 2.5 */ thinkingBudget?: number; /** The tier suffix that was extracted */ tier?: ThinkingTier; /** Whether this is a thinking-capable model */ isThinkingModel?: boolean; /** Whether this is an image generation model */ isImageModel?: boolean; /** Quota preference - all models default to antigravity, with CLI as fallback */ quotaPreference?: HeaderStyle; /** Whether user explicitly specified quota via suffix (vs default selection) */ explicitQuota?: boolean; /** Source of thinking config: "variant" (providerOptions) or "tier" (model suffix) */ configSource?: "variant" | "tier"; /** Google Search configuration from variant or global config */ googleSearch?: GoogleSearchConfig; } ================================================ FILE: src/plugin/types.ts ================================================ import type { PluginInput } from "@opencode-ai/plugin"; import type { AntigravityTokenExchangeResult } from "../antigravity/oauth"; export interface OAuthAuthDetails { type: "oauth"; refresh: string; access?: string; expires?: number; } export interface ApiKeyAuthDetails { type: "api_key"; key: string; } export interface NonOAuthAuthDetails { type: string; [key: string]: unknown; } export type AuthDetails = OAuthAuthDetails | ApiKeyAuthDetails | NonOAuthAuthDetails; export type GetAuth = () => Promise; export interface ProviderModel { cost?: { input: number; output: number; }; [key: string]: unknown; } export interface Provider { models?: Record; } export interface LoaderResult { apiKey: string; fetch(input: RequestInfo, init?: RequestInit): Promise; } export type PluginClient = PluginInput["client"]; export interface PluginContext { client: PluginClient; directory: string; } export type AuthPrompt = | { type: "text"; key: string; message: string; placeholder?: string; validate?: (value: string) => string | undefined; condition?: (inputs: Record) => boolean; } | { type: "select"; key: string; message: string; options: Array<{ label: string; value: string; hint?: string }>; condition?: (inputs: Record) => boolean; }; export type OAuthAuthorizationResult = { url: string; instructions: string } & ( | { method: "auto"; callback: () => Promise; } | { method: "code"; callback: (code: string) => Promise; } ); export interface AuthMethod { provider?: string; label: string; type: "oauth" | "api"; prompts?: AuthPrompt[]; authorize?: (inputs?: Record) => Promise; } export interface PluginEventPayload { event: { type: string; properties?: unknown; }; } export interface PluginResult { auth: { provider: string; loader: (getAuth: GetAuth, provider: Provider) => Promise>; methods: AuthMethod[]; }; event?: (payload: PluginEventPayload) => void; tool?: Record; } export interface RefreshParts { refreshToken: string; projectId?: string; managedProjectId?: string; } export interface ProjectContextResult { auth: OAuthAuthDetails; effectiveProjectId: string; } ================================================ FILE: src/plugin/ui/ansi.test.ts ================================================ import { describe, it, expect } from 'vitest'; import { parseKey, isTTY, ANSI } from './ansi'; describe('ansi', () => { describe('parseKey', () => { it('parses arrow up sequences', () => { expect(parseKey(Buffer.from('\x1b[A'))).toBe('up'); expect(parseKey(Buffer.from('\x1bOA'))).toBe('up'); }); it('parses arrow down sequences', () => { expect(parseKey(Buffer.from('\x1b[B'))).toBe('down'); expect(parseKey(Buffer.from('\x1bOB'))).toBe('down'); }); it('parses enter key (CR and LF)', () => { expect(parseKey(Buffer.from('\r'))).toBe('enter'); expect(parseKey(Buffer.from('\n'))).toBe('enter'); }); it('parses Ctrl+C as escape', () => { expect(parseKey(Buffer.from('\x03'))).toBe('escape'); }); it('parses bare escape as escape-start', () => { expect(parseKey(Buffer.from('\x1b'))).toBe('escape-start'); }); it('returns null for unknown keys', () => { expect(parseKey(Buffer.from('a'))).toBe(null); expect(parseKey(Buffer.from('1'))).toBe(null); expect(parseKey(Buffer.from(' '))).toBe(null); expect(parseKey(Buffer.from('\t'))).toBe(null); }); it('returns null for partial escape sequences', () => { expect(parseKey(Buffer.from('\x1b['))).toBe(null); expect(parseKey(Buffer.from('\x1bO'))).toBe(null); }); it('returns null for other arrow keys', () => { expect(parseKey(Buffer.from('\x1b[C'))).toBe(null); expect(parseKey(Buffer.from('\x1b[D'))).toBe(null); }); }); describe('ANSI codes', () => { it('has cursor control codes', () => { expect(ANSI.hide).toBe('\x1b[?25l'); expect(ANSI.show).toBe('\x1b[?25h'); expect(ANSI.clearLine).toBe('\x1b[2K'); }); it('generates cursor movement codes', () => { expect(ANSI.up(1)).toBe('\x1b[1A'); expect(ANSI.up(5)).toBe('\x1b[5A'); expect(ANSI.down(1)).toBe('\x1b[1B'); expect(ANSI.down(3)).toBe('\x1b[3B'); }); it('has color codes', () => { expect(ANSI.cyan).toBe('\x1b[36m'); expect(ANSI.green).toBe('\x1b[32m'); expect(ANSI.red).toBe('\x1b[31m'); expect(ANSI.yellow).toBe('\x1b[33m'); expect(ANSI.reset).toBe('\x1b[0m'); }); it('has style codes', () => { expect(ANSI.dim).toBe('\x1b[2m'); expect(ANSI.bold).toBe('\x1b[1m'); }); }); describe('isTTY', () => { it('returns boolean', () => { expect(typeof isTTY()).toBe('boolean'); }); }); }); ================================================ FILE: src/plugin/ui/ansi.ts ================================================ /** * ANSI escape codes and key parsing for interactive CLI menus. * Works cross-platform (Windows/Mac/Linux). */ export const ANSI = { // Cursor control hide: '\x1b[?25l', show: '\x1b[?25h', up: (n = 1) => `\x1b[${n}A`, down: (n = 1) => `\x1b[${n}B`, clearLine: '\x1b[2K', clearScreen: '\x1b[2J', moveTo: (row: number, col: number) => `\x1b[${row};${col}H`, // Styles cyan: '\x1b[36m', green: '\x1b[32m', red: '\x1b[31m', yellow: '\x1b[33m', dim: '\x1b[2m', bold: '\x1b[1m', reset: '\x1b[0m', inverse: '\x1b[7m', } as const; export type KeyAction = 'up' | 'down' | 'enter' | 'escape' | 'escape-start' | null; /** * Parse raw keyboard input buffer into a key action. * Handles Windows/Mac/Linux differences in arrow key sequences. */ export function parseKey(data: Buffer): KeyAction { const s = data.toString(); // Arrow keys (ANSI escape sequences) // Standard: \x1b[A (up), \x1b[B (down) // Application mode: \x1bOA (up), \x1bOB (down) if (s === '\x1b[A' || s === '\x1bOA') return 'up'; if (s === '\x1b[B' || s === '\x1bOB') return 'down'; // Enter (CR or LF) if (s === '\r' || s === '\n') return 'enter'; if (s === '\x03') return 'escape'; if (s === '\x1b') return 'escape-start'; return null; } /** * Check if the terminal supports interactive input. */ export function isTTY(): boolean { return Boolean(process.stdin.isTTY); } ================================================ FILE: src/plugin/ui/auth-menu.test.ts ================================================ import { describe, it, expect } from 'vitest'; import { ANSI } from './ansi'; function formatRelativeTime(timestamp: number | undefined): string { if (!timestamp) return 'never'; const days = Math.floor((Date.now() - timestamp) / 86400000); if (days === 0) return 'today'; if (days === 1) return 'yesterday'; if (days < 7) return `${days}d ago`; if (days < 30) return `${Math.floor(days / 7)}w ago`; return new Date(timestamp).toLocaleDateString(); } function formatDate(timestamp: number | undefined): string { if (!timestamp) return 'unknown'; return new Date(timestamp).toLocaleDateString(); } type AccountStatus = 'active' | 'rate-limited' | 'expired' | 'unknown'; function getStatusBadge(status: AccountStatus | undefined): string { switch (status) { case 'active': return `${ANSI.green}[active]${ANSI.reset}`; case 'rate-limited': return `${ANSI.yellow}[rate-limited]${ANSI.reset}`; case 'expired': return `${ANSI.red}[expired]${ANSI.reset}`; default: return ''; } } describe('auth-menu helpers', () => { describe('formatRelativeTime', () => { it('returns "never" for undefined', () => { expect(formatRelativeTime(undefined)).toBe('never'); }); it('returns "today" for same day', () => { expect(formatRelativeTime(Date.now())).toBe('today'); expect(formatRelativeTime(Date.now() - 1000)).toBe('today'); }); it('returns "yesterday" for 1 day ago', () => { const yesterday = Date.now() - 86400000; expect(formatRelativeTime(yesterday)).toBe('yesterday'); }); it('returns "Xd ago" for 2-6 days', () => { expect(formatRelativeTime(Date.now() - 2 * 86400000)).toBe('2d ago'); expect(formatRelativeTime(Date.now() - 6 * 86400000)).toBe('6d ago'); }); it('returns "Xw ago" for 7-29 days', () => { expect(formatRelativeTime(Date.now() - 7 * 86400000)).toBe('1w ago'); expect(formatRelativeTime(Date.now() - 14 * 86400000)).toBe('2w ago'); expect(formatRelativeTime(Date.now() - 28 * 86400000)).toBe('4w ago'); }); it('returns formatted date for 30+ days', () => { const oldDate = Date.now() - 60 * 86400000; const result = formatRelativeTime(oldDate); expect(result).not.toBe('never'); expect(result).not.toContain('ago'); }); }); describe('formatDate', () => { it('returns "unknown" for undefined', () => { expect(formatDate(undefined)).toBe('unknown'); }); it('returns formatted date for valid timestamp', () => { const result = formatDate(Date.now()); expect(result).not.toBe('unknown'); expect(typeof result).toBe('string'); }); }); describe('getStatusBadge', () => { it('returns green badge for active status', () => { const badge = getStatusBadge('active'); expect(badge).toContain('[active]'); expect(badge).toContain(ANSI.green); }); it('returns yellow badge for rate-limited status', () => { const badge = getStatusBadge('rate-limited'); expect(badge).toContain('[rate-limited]'); expect(badge).toContain(ANSI.yellow); }); it('returns red badge for expired status', () => { const badge = getStatusBadge('expired'); expect(badge).toContain('[expired]'); expect(badge).toContain(ANSI.red); }); it('returns empty string for unknown status', () => { expect(getStatusBadge('unknown')).toBe(''); expect(getStatusBadge(undefined)).toBe(''); }); }); }); ================================================ FILE: src/plugin/ui/auth-menu.ts ================================================ import { ANSI } from './ansi'; import { select, type MenuItem } from './select'; import { confirm } from './confirm'; export type AccountStatus = 'active' | 'rate-limited' | 'expired' | 'verification-required' | 'unknown'; export interface AccountInfo { email?: string; index: number; addedAt?: number; lastUsed?: number; status?: AccountStatus; isCurrentAccount?: boolean; enabled?: boolean; } export type AuthMenuAction = | { type: 'add' } | { type: 'select-account'; account: AccountInfo } | { type: 'delete-all' } | { type: 'check' } | { type: 'verify' } | { type: 'verify-all' } | { type: 'configure-models' } | { type: 'cancel' }; export type AccountAction = 'back' | 'delete' | 'refresh' | 'toggle' | 'verify' | 'cancel'; function formatRelativeTime(timestamp: number | undefined): string { if (!timestamp) return 'never'; const days = Math.floor((Date.now() - timestamp) / 86400000); if (days === 0) return 'today'; if (days === 1) return 'yesterday'; if (days < 7) return `${days}d ago`; if (days < 30) return `${Math.floor(days / 7)}w ago`; return new Date(timestamp).toLocaleDateString(); } function formatDate(timestamp: number | undefined): string { if (!timestamp) return 'unknown'; return new Date(timestamp).toLocaleDateString(); } function getStatusBadge(status: AccountStatus | undefined): string { switch (status) { case 'active': return `${ANSI.green}[active]${ANSI.reset}`; case 'rate-limited': return `${ANSI.yellow}[rate-limited]${ANSI.reset}`; case 'expired': return `${ANSI.red}[expired]${ANSI.reset}`; case 'verification-required': return `${ANSI.red}[needs verification]${ANSI.reset}`; default: return ''; } } export async function showAuthMenu(accounts: AccountInfo[]): Promise { const items: MenuItem[] = [ { label: 'Actions', value: { type: 'cancel' }, kind: 'heading' }, { label: 'Add account', value: { type: 'add' }, color: 'cyan' }, { label: 'Check quotas', value: { type: 'check' }, color: 'cyan' }, { label: 'Verify one account', value: { type: 'verify' }, color: 'cyan' }, { label: 'Verify all accounts', value: { type: 'verify-all' }, color: 'cyan' }, { label: 'Configure models in opencode.json', value: { type: 'configure-models' }, color: 'cyan' }, { label: '', value: { type: 'cancel' }, separator: true }, { label: 'Accounts', value: { type: 'cancel' }, kind: 'heading' }, ...accounts.map(account => { const statusBadge = getStatusBadge(account.status); const currentBadge = account.isCurrentAccount ? ` ${ANSI.cyan}[current]${ANSI.reset}` : ''; const disabledBadge = account.enabled === false ? ` ${ANSI.red}[disabled]${ANSI.reset}` : ''; const baseLabel = account.email || `Account ${account.index + 1}`; const numbered = `${account.index + 1}. ${baseLabel}`; const fullLabel = `${numbered}${currentBadge}${statusBadge ? ' ' + statusBadge : ''}${disabledBadge}`; return { label: fullLabel, hint: account.lastUsed ? `used ${formatRelativeTime(account.lastUsed)}` : '', value: { type: 'select-account' as const, account }, }; }), { label: '', value: { type: 'cancel' }, separator: true }, { label: 'Danger zone', value: { type: 'cancel' }, kind: 'heading' }, { label: 'Delete all accounts', value: { type: 'delete-all' }, color: 'red' as const }, ]; while (true) { const result = await select(items, { message: 'Google accounts (Antigravity)', subtitle: 'Select an action or account', clearScreen: true, }); if (!result) return { type: 'cancel' }; if (result.type === 'delete-all') { const confirmed = await confirm('Delete ALL accounts? This cannot be undone.'); if (!confirmed) continue; } return result; } } export async function showAccountDetails(account: AccountInfo): Promise { const label = account.email || `Account ${account.index + 1}`; const badge = getStatusBadge(account.status); const disabledBadge = account.enabled === false ? ` ${ANSI.red}[disabled]${ANSI.reset}` : ''; const header = `${label}${badge ? ' ' + badge : ''}${disabledBadge}`; const subtitleParts = [ `Added: ${formatDate(account.addedAt)}`, `Last used: ${formatRelativeTime(account.lastUsed)}`, ]; while (true) { const result = await select([ { label: 'Back', value: 'back' as const }, { label: 'Verify account access', value: 'verify' as const, color: 'cyan' }, { label: account.enabled === false ? 'Enable account' : 'Disable account', value: 'toggle' as const, color: account.enabled === false ? 'green' : 'yellow' }, { label: 'Refresh token', value: 'refresh' as const, color: 'cyan' }, { label: 'Delete this account', value: 'delete' as const, color: 'red' }, ], { message: header, subtitle: subtitleParts.join(' | '), clearScreen: true, }); if (result === 'delete') { const confirmed = await confirm(`Delete ${label}?`); if (!confirmed) continue; } if (result === 'refresh') { const confirmed = await confirm(`Re-authenticate ${label}?`); if (!confirmed) continue; } return result ?? 'cancel'; } } export { isTTY } from './ansi'; ================================================ FILE: src/plugin/ui/confirm.ts ================================================ import { select } from './select'; export async function confirm(message: string, defaultYes = false): Promise { const items = defaultYes ? [ { label: 'Yes', value: true }, { label: 'No', value: false }, ] : [ { label: 'No', value: false }, { label: 'Yes', value: true }, ]; const result = await select(items, { message }); return result ?? false; } ================================================ FILE: src/plugin/ui/select.ts ================================================ import { ANSI, isTTY, parseKey } from './ansi'; export interface MenuItem { label: string; value: T; hint?: string; disabled?: boolean; separator?: boolean; /** Non-selectable label row (section heading). */ kind?: 'heading'; color?: 'red' | 'green' | 'yellow' | 'cyan'; } export interface SelectOptions { message: string; subtitle?: string; /** Override the help line shown at the bottom of the menu. */ help?: string; /** * Clear the terminal before each render (opt-in). * Useful for nested flows where previous logs make menus feel cluttered. */ clearScreen?: boolean; } const ESCAPE_TIMEOUT_MS = 50; const ANSI_REGEX = new RegExp("\\x1b\\[[0-9;]*m", "g"); const ANSI_LEADING_REGEX = new RegExp("^\\x1b\\[[0-9;]*m"); function stripAnsi(input: string): string { return input.replace(ANSI_REGEX, ''); } function truncateAnsi(input: string, maxVisibleChars: number): string { if (maxVisibleChars <= 0) return ''; const visible = stripAnsi(input); if (visible.length <= maxVisibleChars) return input; const suffix = maxVisibleChars >= 3 ? '...' : '.'.repeat(maxVisibleChars); const keep = Math.max(0, maxVisibleChars - suffix.length); let out = ''; let i = 0; let kept = 0; while (i < input.length && kept < keep) { // Preserve ANSI sequences without counting them. if (input[i] === '\x1b') { const m = input.slice(i).match(ANSI_LEADING_REGEX); if (m) { out += m[0]; i += m[0].length; continue; } } out += input[i]; i += 1; kept += 1; } if (out.includes('\x1b[')) { return `${out}${ANSI.reset}${suffix}`; } return out + suffix; } function getColorCode(color: MenuItem['color']): string { switch (color) { case 'red': return ANSI.red; case 'green': return ANSI.green; case 'yellow': return ANSI.yellow; case 'cyan': return ANSI.cyan; default: return ''; } } export async function select( items: MenuItem[], options: SelectOptions ): Promise { if (!isTTY()) { throw new Error('Interactive select requires a TTY terminal'); } if (items.length === 0) { throw new Error('No menu items provided'); } const isSelectable = (i: MenuItem) => !i.disabled && !i.separator && i.kind !== 'heading'; const enabledItems = items.filter(isSelectable); if (enabledItems.length === 0) { throw new Error('All items disabled'); } if (enabledItems.length === 1) { return enabledItems[0]!.value; } const { message, subtitle } = options; const { stdin, stdout } = process; let cursor = items.findIndex(isSelectable); if (cursor === -1) cursor = 0; // Fallback, though validation above should prevent this let escapeTimeout: ReturnType | null = null; let isCleanedUp = false; let renderedLines = 0; const render = () => { const columns = stdout.columns ?? 80; const rows = stdout.rows ?? 24; const shouldClearScreen = options.clearScreen === true; const previousRenderedLines = renderedLines; if (shouldClearScreen) { stdout.write(ANSI.clearScreen + ANSI.moveTo(1, 1)); } else if (previousRenderedLines > 0) { stdout.write(ANSI.up(previousRenderedLines)); } let linesWritten = 0; const writeLine = (line: string) => { stdout.write(`${ANSI.clearLine}${line}\n`); linesWritten += 1; }; // Subtitle renders as 3 lines: // 1) blank "│" spacer, 2) subtitle line, 3) blank line. Header is counted separately. const subtitleLines = subtitle ? 3 : 0; const fixedLines = 1 + subtitleLines + 2; // header + subtitle + (help + bottom) // Keep a small safety margin so the final newline doesn't scroll the terminal. const maxVisibleItems = Math.max(1, Math.min(items.length, rows - fixedLines - 1)); // If the menu is taller than the viewport, only render a window around the cursor. // This prevents terminal scrollback spam (e.g. repeated headers when pressing arrows). let windowStart = 0; let windowEnd = items.length; if (items.length > maxVisibleItems) { windowStart = cursor - Math.floor(maxVisibleItems / 2); windowStart = Math.max(0, Math.min(windowStart, items.length - maxVisibleItems)); windowEnd = windowStart + maxVisibleItems; } const visibleItems = items.slice(windowStart, windowEnd); const headerMessage = truncateAnsi(message, Math.max(1, columns - 4)); writeLine(`${ANSI.dim}┌ ${ANSI.reset}${headerMessage}`); if (subtitle) { writeLine(`${ANSI.dim}│${ANSI.reset}`); const sub = truncateAnsi(subtitle, Math.max(1, columns - 4)); writeLine(`${ANSI.cyan}◆${ANSI.reset} ${sub}`); writeLine(""); } for (let i = 0; i < visibleItems.length; i++) { const itemIndex = windowStart + i; const item = visibleItems[i]; if (!item) continue; if (item.separator) { writeLine(`${ANSI.dim}│${ANSI.reset}`); continue; } if (item.kind === 'heading') { const heading = truncateAnsi(`${ANSI.dim}${ANSI.bold}${item.label}${ANSI.reset}`, Math.max(1, columns - 6)); writeLine(`${ANSI.cyan}│${ANSI.reset} ${heading}`); continue; } const isSelected = itemIndex === cursor; const colorCode = getColorCode(item.color); let labelText: string; if (item.disabled) { labelText = `${ANSI.dim}${item.label} (unavailable)${ANSI.reset}`; } else if (isSelected) { labelText = colorCode ? `${colorCode}${item.label}${ANSI.reset}` : item.label; if (item.hint) labelText += ` ${ANSI.dim}${item.hint}${ANSI.reset}`; } else { labelText = colorCode ? `${ANSI.dim}${colorCode}${item.label}${ANSI.reset}` : `${ANSI.dim}${item.label}${ANSI.reset}`; if (item.hint) labelText += ` ${ANSI.dim}${item.hint}${ANSI.reset}`; } // Prevent wrapping: cursor positioning relies on a fixed line count. labelText = truncateAnsi(labelText, Math.max(1, columns - 8)); if (isSelected) { writeLine(`${ANSI.cyan}│${ANSI.reset} ${ANSI.green}●${ANSI.reset} ${labelText}`); } else { writeLine(`${ANSI.cyan}│${ANSI.reset} ${ANSI.dim}○${ANSI.reset} ${labelText}`); } } const windowHint = items.length > visibleItems.length ? ` (${windowStart + 1}-${windowEnd}/${items.length})` : ''; const helpText = options.help ?? `Up/Down to select | Enter: confirm | Esc: back${windowHint}`; const help = truncateAnsi(helpText, Math.max(1, columns - 6)); writeLine(`${ANSI.cyan}│${ANSI.reset} ${ANSI.dim}${help}${ANSI.reset}`); writeLine(`${ANSI.cyan}└${ANSI.reset}`); if (!shouldClearScreen && previousRenderedLines > linesWritten) { const extra = previousRenderedLines - linesWritten; for (let i = 0; i < extra; i++) { writeLine(""); } } renderedLines = linesWritten; }; return new Promise((resolve) => { const wasRaw = stdin.isRaw ?? false; const cleanup = () => { if (isCleanedUp) return; isCleanedUp = true; if (escapeTimeout) { clearTimeout(escapeTimeout); escapeTimeout = null; } try { stdin.removeListener('data', onKey); stdin.setRawMode(wasRaw); stdin.pause(); stdout.write(ANSI.show); } catch { // Intentionally ignored - cleanup is best-effort } process.removeListener('SIGINT', onSignal); process.removeListener('SIGTERM', onSignal); }; const onSignal = () => { cleanup(); resolve(null); }; const finishWithValue = (value: T | null) => { cleanup(); resolve(value); }; const findNextSelectable = (from: number, direction: 1 | -1): number => { if (items.length === 0) return from; let next = from; do { next = (next + direction + items.length) % items.length; } while (items[next]?.disabled || items[next]?.separator || items[next]?.kind === 'heading'); return next; }; const onKey = (data: Buffer) => { if (escapeTimeout) { clearTimeout(escapeTimeout); escapeTimeout = null; } const action = parseKey(data); switch (action) { case 'up': cursor = findNextSelectable(cursor, -1); render(); return; case 'down': cursor = findNextSelectable(cursor, 1); render(); return; case 'enter': finishWithValue(items[cursor]?.value ?? null); return; case 'escape': finishWithValue(null); return; case 'escape-start': // Bare escape byte - wait to see if more bytes coming (arrow key sequence) escapeTimeout = setTimeout(() => { finishWithValue(null); }, ESCAPE_TIMEOUT_MS); return; default: // Unknown key - ignore return; } }; process.once('SIGINT', onSignal); process.once('SIGTERM', onSignal); try { stdin.setRawMode(true); } catch { // Failed to enable raw mode - cleanup and return null cleanup(); resolve(null); return; } stdin.resume(); stdout.write(ANSI.hide); render(); stdin.on('data', onKey); }); } ================================================ FILE: src/plugin/version.test.ts ================================================ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest" /** * Regression tests for the version fallback mechanism. * * Issue #468: On WSL2/AlmaLinux with strict firewall rules, both the * auto-updater API and changelog fetch fail. The plugin then uses the * hardcoded fallback version in User-Agent headers. If the fallback is * too old, the backend rejects requests for newer models (e.g., Gemini 3.1 Pro) * with "not available on this version". * * These tests verify the fallback is current and that the * network-failure path correctly uses it. */ // Reset module state between tests so versionLocked starts fresh beforeEach(() => { vi.resetModules() }) afterEach(() => { vi.unstubAllGlobals() }) describe("ANTIGRAVITY_VERSION_FALLBACK", () => { it("defaults to the exported fallback constant", async () => { const { ANTIGRAVITY_VERSION_FALLBACK, getAntigravityVersion } = await import("../constants.ts") expect(getAntigravityVersion()).toBe(ANTIGRAVITY_VERSION_FALLBACK) }) it("is at least 1.18.0 to support Gemini 3.1 Pro", async () => { const { getAntigravityVersion } = await import("../constants.ts") const [major, minor] = getAntigravityVersion().split(".").map(Number) expect(major).toBeGreaterThanOrEqual(1) if (major === 1) expect(minor).toBeGreaterThanOrEqual(18) }) }) describe("setAntigravityVersion", () => { it("updates the version on first call", async () => { const { getAntigravityVersion, setAntigravityVersion } = await import("../constants.ts") setAntigravityVersion("2.0.0") expect(getAntigravityVersion()).toBe("2.0.0") }) it("locks after first call — subsequent calls are ignored", async () => { const { getAntigravityVersion, setAntigravityVersion } = await import("../constants.ts") setAntigravityVersion("2.0.0") setAntigravityVersion("3.0.0") expect(getAntigravityVersion()).toBe("2.0.0") }) }) describe("initAntigravityVersion — network failure path", () => { it("falls back to hardcoded version when both fetches throw", async () => { vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("network unreachable"))) const { ANTIGRAVITY_VERSION_FALLBACK, getAntigravityVersion } = await import("../constants.ts") const { initAntigravityVersion } = await import("./version.ts") await initAntigravityVersion() expect(getAntigravityVersion()).toBe(ANTIGRAVITY_VERSION_FALLBACK) }) it("falls back to hardcoded version when both fetches return non-ok", async () => { vi.stubGlobal( "fetch", vi.fn().mockResolvedValue({ ok: false, status: 503, text: async () => "" }), ) const { ANTIGRAVITY_VERSION_FALLBACK, getAntigravityVersion } = await import("../constants.ts") const { initAntigravityVersion } = await import("./version.ts") await initAntigravityVersion() expect(getAntigravityVersion()).toBe(ANTIGRAVITY_VERSION_FALLBACK) }) it("uses API version when auto-updater responds", async () => { vi.stubGlobal( "fetch", vi.fn().mockResolvedValue({ ok: true, text: async () => "1.19.0" }), ) const { getAntigravityVersion } = await import("../constants.ts") const { initAntigravityVersion } = await import("./version.ts") await initAntigravityVersion() expect(getAntigravityVersion()).toBe("1.19.0") }) it("fallback version appears in User-Agent header", async () => { vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("timeout"))) const { ANTIGRAVITY_VERSION_FALLBACK, getAntigravityHeaders } = await import("../constants.ts") const { initAntigravityVersion } = await import("./version.ts") await initAntigravityVersion() const headers = getAntigravityHeaders() expect(headers["User-Agent"]).toContain(`Antigravity/${ANTIGRAVITY_VERSION_FALLBACK}`) }) it("fallback version appears in randomized antigravity headers", async () => { vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("timeout"))) const { ANTIGRAVITY_VERSION_FALLBACK, getRandomizedHeaders } = await import("../constants.ts") const { initAntigravityVersion } = await import("./version.ts") await initAntigravityVersion() const headers = getRandomizedHeaders("antigravity") expect(headers["User-Agent"]).toContain(ANTIGRAVITY_VERSION_FALLBACK) }) }) ================================================ FILE: src/plugin/version.ts ================================================ /** * Remote Antigravity version fetcher. * * Mirrors the Antigravity-Manager's version resolution strategy: * 1. Auto-updater API (plain text with semver) * 2. Changelog page scrape (first 5000 chars) * 3. Hardcoded fallback in constants.ts * * Called once at plugin startup to ensure headers use the latest * supported version, avoiding "version no longer supported" errors. * * @see https://github.com/lbjlaq/Antigravity-Manager (src-tauri/src/constants.rs) */ import { getAntigravityVersion, setAntigravityVersion } from "../constants"; import { createLogger } from "./logger"; const VERSION_URL = "https://antigravity-auto-updater-974169037036.us-central1.run.app"; const CHANGELOG_URL = "https://antigravity.google/changelog"; const FETCH_TIMEOUT_MS = 5000; const CHANGELOG_SCAN_CHARS = 5000; const VERSION_REGEX = /\d+\.\d+\.\d+/; type VersionSource = "api" | "changelog" | "fallback"; function parseVersion(text: string): string | null { const match = text.match(VERSION_REGEX); return match ? match[0] : null; } async function tryFetchVersion(url: string, maxChars?: number): Promise { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); try { const response = await fetch(url, { signal: controller.signal }); if (!response.ok) return null; let text = await response.text(); if (maxChars) text = text.slice(0, maxChars); return parseVersion(text); } catch { return null; } finally { clearTimeout(timeout); } } /** * Fetch the latest Antigravity version and update the global constant. * Safe to call before logger is initialized (will silently skip logging). */ export async function initAntigravityVersion(): Promise { const log = createLogger("version"); const fallback = getAntigravityVersion(); let version: string | null; let source: VersionSource; // 1. Try auto-updater API version = await tryFetchVersion(VERSION_URL); if (version) { source = "api"; } else { // 2. Try changelog page scrape version = await tryFetchVersion(CHANGELOG_URL, CHANGELOG_SCAN_CHARS); if (version) { source = "changelog"; } else { // 3. Fall back to hardcoded source = "fallback"; setAntigravityVersion(fallback); log.info("version-fetch-failed", { fallback }); return; } } if (version !== fallback) { log.info("version-updated", { version, source, previous: fallback }); } else { log.debug("version-unchanged", { version, source }); } setAntigravityVersion(version); } ================================================ FILE: src/plugin.ts ================================================ import { exec } from "node:child_process"; import { tool } from "@opencode-ai/plugin"; import { ANTIGRAVITY_DEFAULT_PROJECT_ID, ANTIGRAVITY_ENDPOINT_FALLBACKS, ANTIGRAVITY_ENDPOINT_PROD, ANTIGRAVITY_PROVIDER_ID, getAntigravityHeaders, type HeaderStyle, } from "./constants"; import { authorizeAntigravity, exchangeAntigravity } from "./antigravity/oauth"; import type { AntigravityTokenExchangeResult } from "./antigravity/oauth"; import { accessTokenExpired, isOAuthAuth, parseRefreshParts, formatRefreshParts } from "./plugin/auth"; import { promptAddAnotherAccount, promptLoginMode, promptProjectId } from "./plugin/cli"; import { ensureProjectContext } from "./plugin/project"; import { startAntigravityDebugRequest, logAntigravityDebugResponse, logAccountContext, logRateLimitEvent, logRateLimitSnapshot, logResponseBody, logModelFamily, isDebugEnabled, getLogFilePath, initializeDebug, } from "./plugin/debug"; import { buildThinkingWarmupBody, isGenerativeLanguageRequest, prepareAntigravityRequest, transformAntigravityResponse, } from "./plugin/request"; import { resolveModelWithTier } from "./plugin/transform/model-resolver"; import { isEmptyResponseBody, createSyntheticErrorResponse, } from "./plugin/request-helpers"; import { EmptyResponseError } from "./plugin/errors"; import { AntigravityTokenRefreshError, refreshAccessToken } from "./plugin/token"; import { startOAuthListener, type OAuthListener } from "./plugin/server"; import { clearAccounts, loadAccounts, saveAccounts, saveAccountsReplace } from "./plugin/storage"; import { AccountManager, type ModelFamily, parseRateLimitReason, calculateBackoffMs, computeSoftQuotaCacheTtlMs } from "./plugin/accounts"; import { createAutoUpdateCheckerHook } from "./hooks/auto-update-checker"; import { loadConfig, initRuntimeConfig, type AntigravityConfig } from "./plugin/config"; import { createSessionRecoveryHook, getRecoverySuccessToast } from "./plugin/recovery"; import { checkAccountsQuota } from "./plugin/quota"; import { initDiskSignatureCache } from "./plugin/cache"; import { createProactiveRefreshQueue, type ProactiveRefreshQueue } from "./plugin/refresh-queue"; import { initLogger, createLogger } from "./plugin/logger"; import { initHealthTracker, getHealthTracker, initTokenTracker, getTokenTracker } from "./plugin/rotation"; import { initAntigravityVersion } from "./plugin/version"; import { executeSearch } from "./plugin/search"; import type { GetAuth, LoaderResult, PluginClient, PluginContext, PluginResult, ProjectContextResult, Provider, } from "./plugin/types"; const MAX_OAUTH_ACCOUNTS = 10; const MAX_WARMUP_SESSIONS = 1000; const MAX_WARMUP_RETRIES = 2; const CAPACITY_BACKOFF_TIERS_MS = [5000, 10000, 20000, 30000, 60000]; function getCapacityBackoffDelay(consecutiveFailures: number): number { const index = Math.min(consecutiveFailures, CAPACITY_BACKOFF_TIERS_MS.length - 1); return CAPACITY_BACKOFF_TIERS_MS[Math.max(0, index)] ?? 5000; } const warmupAttemptedSessionIds = new Set(); const warmupSucceededSessionIds = new Set(); // Track if this plugin instance is running in a child session (subagent, background task) // Used to filter toasts based on toast_scope config let isChildSession = false; let childSessionParentID: string | undefined = undefined; const log = createLogger("plugin"); // Module-level toast debounce to persist across requests (fixes toast spam) const rateLimitToastCooldowns = new Map(); const RATE_LIMIT_TOAST_COOLDOWN_MS = 5000; const MAX_TOAST_COOLDOWN_ENTRIES = 100; // Track if "all accounts blocked" toasts were shown to prevent spam in while loop let softQuotaToastShown = false; let rateLimitToastShown = false; // Module-level reference to AccountManager for access from auth.login let activeAccountManager: import("./plugin/accounts").AccountManager | null = null; function cleanupToastCooldowns(): void { if (rateLimitToastCooldowns.size > MAX_TOAST_COOLDOWN_ENTRIES) { const now = Date.now(); for (const [key, time] of rateLimitToastCooldowns) { if (now - time > RATE_LIMIT_TOAST_COOLDOWN_MS * 2) { rateLimitToastCooldowns.delete(key); } } } } function shouldShowRateLimitToast(message: string): boolean { cleanupToastCooldowns(); const toastKey = message.replace(/\d+/g, "X"); const lastShown = rateLimitToastCooldowns.get(toastKey) ?? 0; const now = Date.now(); if (now - lastShown < RATE_LIMIT_TOAST_COOLDOWN_MS) { return false; } rateLimitToastCooldowns.set(toastKey, now); return true; } function resetAllAccountsBlockedToasts(): void { softQuotaToastShown = false; rateLimitToastShown = false; } const quotaRefreshInProgressByEmail = new Set(); async function triggerAsyncQuotaRefreshForAccount( accountManager: AccountManager, accountIndex: number, client: PluginClient, providerId: string, intervalMinutes: number, ): Promise { if (intervalMinutes <= 0) return; const accounts = accountManager.getAccounts(); const account = accounts[accountIndex]; if (!account || account.enabled === false) return; const accountKey = account.email ?? `idx-${accountIndex}`; if (quotaRefreshInProgressByEmail.has(accountKey)) return; const intervalMs = intervalMinutes * 60 * 1000; const age = account.cachedQuotaUpdatedAt != null ? Date.now() - account.cachedQuotaUpdatedAt : Infinity; if (age < intervalMs) return; quotaRefreshInProgressByEmail.add(accountKey); try { const accountsForCheck = accountManager.getAccountsForQuotaCheck(); const singleAccount = accountsForCheck[accountIndex]; if (!singleAccount) { quotaRefreshInProgressByEmail.delete(accountKey); return; } const results = await checkAccountsQuota([singleAccount], client, providerId); if (results[0]?.status === "ok" && results[0]?.quota?.groups) { accountManager.updateQuotaCache(accountIndex, results[0].quota.groups); accountManager.requestSaveToDisk(); } } catch (err) { log.debug(`quota-refresh-failed email=${accountKey}`, { error: String(err) }); } finally { quotaRefreshInProgressByEmail.delete(accountKey); } } function trackWarmupAttempt(sessionId: string): boolean { if (warmupSucceededSessionIds.has(sessionId)) { return false; } if (warmupAttemptedSessionIds.size >= MAX_WARMUP_SESSIONS) { const first = warmupAttemptedSessionIds.values().next().value; if (first) { warmupAttemptedSessionIds.delete(first); warmupSucceededSessionIds.delete(first); } } const attempts = getWarmupAttemptCount(sessionId); if (attempts >= MAX_WARMUP_RETRIES) { return false; } warmupAttemptedSessionIds.add(sessionId); return true; } function getWarmupAttemptCount(sessionId: string): number { return warmupAttemptedSessionIds.has(sessionId) ? 1 : 0; } function markWarmupSuccess(sessionId: string): void { warmupSucceededSessionIds.add(sessionId); if (warmupSucceededSessionIds.size >= MAX_WARMUP_SESSIONS) { const first = warmupSucceededSessionIds.values().next().value; if (first) warmupSucceededSessionIds.delete(first); } } function clearWarmupAttempt(sessionId: string): void { warmupAttemptedSessionIds.delete(sessionId); } function isWSL(): boolean { if (process.platform !== "linux") return false; try { const { readFileSync } = require("node:fs"); const release = readFileSync("/proc/version", "utf8").toLowerCase(); return release.includes("microsoft") || release.includes("wsl"); } catch { return false; } } function isWSL2(): boolean { if (!isWSL()) return false; try { const { readFileSync } = require("node:fs"); const version = readFileSync("/proc/version", "utf8").toLowerCase(); return version.includes("wsl2") || version.includes("microsoft-standard"); } catch { return false; } } function isRemoteEnvironment(): boolean { if (process.env.SSH_CLIENT || process.env.SSH_TTY || process.env.SSH_CONNECTION) { return true; } if (process.env.REMOTE_CONTAINERS || process.env.CODESPACES) { return true; } if (process.platform === "linux" && !process.env.DISPLAY && !process.env.WAYLAND_DISPLAY && !isWSL()) { return true; } return false; } function shouldSkipLocalServer(): boolean { return isWSL2() || isRemoteEnvironment(); } async function openBrowser(url: string): Promise { try { if (process.platform === "darwin") { exec(`open "${url}"`); return true; } if (process.platform === "win32") { exec(`start "" "${url}"`); return true; } if (isWSL()) { try { exec(`wslview "${url}"`); return true; } catch {} } if (!process.env.DISPLAY && !process.env.WAYLAND_DISPLAY) { return false; } exec(`xdg-open "${url}"`); return true; } catch { return false; } } type VerificationProbeResult = { status: "ok" | "blocked" | "error"; message: string; verifyUrl?: string; }; function decodeEscapedText(input: string): string { return input .replace(/&/g, "&") .replace(/\\u([0-9a-fA-F]{4})/g, (_, hex: string) => String.fromCharCode(Number.parseInt(hex, 16))); } function normalizeGoogleVerificationUrl(rawUrl: string): string | undefined { const normalized = decodeEscapedText(rawUrl).trim(); if (!normalized) { return undefined; } try { const parsed = new URL(normalized); if (parsed.hostname !== "accounts.google.com") { return undefined; } return parsed.toString(); } catch { return undefined; } } function selectBestVerificationUrl(urls: string[]): string | undefined { const unique = Array.from(new Set(urls.map((url) => normalizeGoogleVerificationUrl(url)).filter(Boolean) as string[])); if (unique.length === 0) { return undefined; } unique.sort((a, b) => { const score = (value: string): number => { let total = 0; if (value.includes("plt=")) total += 4; if (value.includes("/signin/continue")) total += 3; if (value.includes("continue=")) total += 2; if (value.includes("service=cloudcode")) total += 1; return total; }; return score(b) - score(a); }); return unique[0]; } function extractVerificationErrorDetails(bodyText: string): { validationRequired: boolean; message?: string; verifyUrl?: string; } { const decodedBody = decodeEscapedText(bodyText); const lowerBody = decodedBody.toLowerCase(); let validationRequired = lowerBody.includes("validation_required"); let message: string | undefined; const verificationUrls = new Set(); const collectUrlsFromText = (text: string): void => { for (const match of text.matchAll(/https:\/\/accounts\.google\.com\/[^\s"'<>]+/gi)) { if (match[0]) { verificationUrls.add(match[0]); } } }; collectUrlsFromText(decodedBody); const payloads: unknown[] = []; const trimmed = decodedBody.trim(); if (trimmed.startsWith("{") || trimmed.startsWith("[")) { try { payloads.push(JSON.parse(trimmed)); } catch { } } for (const rawLine of decodedBody.split("\n")) { const line = rawLine.trim(); if (!line.startsWith("data:")) { continue; } const payloadText = line.slice(5).trim(); if (!payloadText || payloadText === "[DONE]") { continue; } try { payloads.push(JSON.parse(payloadText)); } catch { collectUrlsFromText(payloadText); } } const visited = new Set(); const walk = (value: unknown, key?: string): void => { if (typeof value === "string") { const normalizedValue = decodeEscapedText(value); const lowerValue = normalizedValue.toLowerCase(); const lowerKey = key?.toLowerCase() ?? ""; if (lowerValue.includes("validation_required")) { validationRequired = true; } if ( !message && (lowerKey.includes("message") || lowerKey.includes("detail") || lowerKey.includes("description")) ) { message = normalizedValue; } if ( lowerKey.includes("validation_url") || lowerKey.includes("verify_url") || lowerKey.includes("verification_url") || lowerKey === "url" ) { verificationUrls.add(normalizedValue); } collectUrlsFromText(normalizedValue); return; } if (!value || typeof value !== "object" || visited.has(value)) { return; } visited.add(value); if (Array.isArray(value)) { for (const item of value) { walk(item); } return; } for (const [childKey, childValue] of Object.entries(value as Record)) { walk(childValue, childKey); } }; for (const payload of payloads) { walk(payload); } if (!validationRequired) { validationRequired = lowerBody.includes("verification required") || lowerBody.includes("verify your account") || lowerBody.includes("account verification"); } if (!message) { const fallback = decodedBody .split("\n") .map((line) => line.trim()) .find((line) => line && !line.startsWith("data:") && /(verify|validation|required)/i.test(line)); if (fallback) { message = fallback; } } return { validationRequired, message, verifyUrl: selectBestVerificationUrl([...verificationUrls]), }; } async function verifyAccountAccess( account: { refreshToken: string; email?: string; projectId?: string; managedProjectId?: string; }, client: PluginClient, providerId: string, ): Promise { const parsed = parseRefreshParts(account.refreshToken); if (!parsed.refreshToken) { return { status: "error", message: "Missing refresh token for selected account." }; } const auth = { type: "oauth" as const, refresh: formatRefreshParts({ refreshToken: parsed.refreshToken, projectId: parsed.projectId ?? account.projectId, managedProjectId: parsed.managedProjectId ?? account.managedProjectId, }), access: "", expires: 0, }; let refreshedAuth: Awaited>; try { refreshedAuth = await refreshAccessToken(auth, client, providerId); } catch (error) { if (error instanceof AntigravityTokenRefreshError) { return { status: "error", message: error.message }; } return { status: "error", message: `Token refresh failed: ${String(error)}` }; } if (!refreshedAuth?.access) { return { status: "error", message: "Could not refresh access token for this account." }; } const projectId = parsed.managedProjectId ?? parsed.projectId ?? account.managedProjectId ?? account.projectId ?? ANTIGRAVITY_DEFAULT_PROJECT_ID; const headers: Record = { ...getAntigravityHeaders(), Authorization: `Bearer ${refreshedAuth.access}`, "Content-Type": "application/json", }; if (projectId) { headers["x-goog-user-project"] = projectId; } const requestBody = { model: "gemini-3-flash", request: { model: "gemini-3-flash", contents: [{ role: "user", parts: [{ text: "ping" }] }], generationConfig: { maxOutputTokens: 1, temperature: 0 }, }, }; const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 20000); let response: Response; try { response = await fetch(`${ANTIGRAVITY_ENDPOINT_PROD}/v1internal:streamGenerateContent?alt=sse`, { method: "POST", headers, body: JSON.stringify(requestBody), signal: controller.signal, }); } catch (error) { if (error instanceof Error && error.name === "AbortError") { return { status: "error", message: "Verification check timed out." }; } return { status: "error", message: `Verification check failed: ${String(error)}` }; } finally { clearTimeout(timeoutId); } let responseBody = ""; try { responseBody = await response.text(); } catch { responseBody = ""; } if (response.ok) { return { status: "ok", message: "Account verification check passed." }; } const extracted = extractVerificationErrorDetails(responseBody); if (response.status === 403 && extracted.validationRequired) { return { status: "blocked", message: extracted.message ?? "Google requires additional account verification.", verifyUrl: extracted.verifyUrl, }; } const fallbackMessage = extracted.message ?? `Request failed (${response.status} ${response.statusText}).`; return { status: "error", message: fallbackMessage, }; } async function promptAccountIndexForVerification( accounts: Array<{ email?: string; index: number }>, ): Promise { const { createInterface } = await import("node:readline/promises"); const { stdin, stdout } = await import("node:process"); const rl = createInterface({ input: stdin, output: stdout }); try { console.log("\nSelect an account to verify:"); for (const account of accounts) { const label = account.email || `Account ${account.index + 1}`; console.log(` ${account.index + 1}. ${label}`); } console.log(""); while (true) { const answer = (await rl.question("Account number (leave blank to cancel): ")).trim(); if (!answer) { return undefined; } const parsedIndex = Number(answer); if (!Number.isInteger(parsedIndex)) { console.log("Please enter a valid account number."); continue; } const normalizedIndex = parsedIndex - 1; const selected = accounts.find((account) => account.index === normalizedIndex); if (!selected) { console.log("Please enter a number from the list above."); continue; } return selected.index; } } finally { rl.close(); } } async function promptOpenVerificationUrl(): Promise { const answer = (await promptOAuthCallbackValue("Open verification URL in your browser now? [Y/n]: ")).trim().toLowerCase(); return answer === "" || answer === "y" || answer === "yes"; } type VerificationStoredAccount = { enabled?: boolean; verificationRequired?: boolean; verificationRequiredAt?: number; verificationRequiredReason?: string; verificationUrl?: string; }; function markStoredAccountVerificationRequired( account: VerificationStoredAccount, reason: string, verifyUrl?: string, ): boolean { let changed = false; const wasVerificationRequired = account.verificationRequired === true; if (!wasVerificationRequired) { account.verificationRequired = true; changed = true; } if (!wasVerificationRequired || account.verificationRequiredAt === undefined) { account.verificationRequiredAt = Date.now(); changed = true; } const normalizedReason = reason.trim(); if (account.verificationRequiredReason !== normalizedReason) { account.verificationRequiredReason = normalizedReason; changed = true; } const normalizedUrl = verifyUrl?.trim(); if (normalizedUrl && account.verificationUrl !== normalizedUrl) { account.verificationUrl = normalizedUrl; changed = true; } if (account.enabled !== false) { account.enabled = false; changed = true; } return changed; } function clearStoredAccountVerificationRequired( account: VerificationStoredAccount, enableIfRequired = false, ): { changed: boolean; wasVerificationRequired: boolean } { const wasVerificationRequired = account.verificationRequired === true; let changed = false; if (account.verificationRequired !== false) { account.verificationRequired = false; changed = true; } if (account.verificationRequiredAt !== undefined) { account.verificationRequiredAt = undefined; changed = true; } if (account.verificationRequiredReason !== undefined) { account.verificationRequiredReason = undefined; changed = true; } if (account.verificationUrl !== undefined) { account.verificationUrl = undefined; changed = true; } if (enableIfRequired && wasVerificationRequired && account.enabled === false) { account.enabled = true; changed = true; } return { changed, wasVerificationRequired }; } async function promptOAuthCallbackValue(message: string): Promise { const { createInterface } = await import("node:readline/promises"); const { stdin, stdout } = await import("node:process"); const rl = createInterface({ input: stdin, output: stdout }); try { return (await rl.question(message)).trim(); } finally { rl.close(); } } type OAuthCallbackParams = { code: string; state: string }; function getStateFromAuthorizationUrl(authorizationUrl: string): string { try { return new URL(authorizationUrl).searchParams.get("state") ?? ""; } catch { return ""; } } function extractOAuthCallbackParams(url: URL): OAuthCallbackParams | null { const code = url.searchParams.get("code"); const state = url.searchParams.get("state"); if (!code || !state) { return null; } return { code, state }; } function parseOAuthCallbackInput( value: string, fallbackState: string, ): OAuthCallbackParams | { error: string } { const trimmed = value.trim(); if (!trimmed) { return { error: "Missing authorization code" }; } try { const url = new URL(trimmed); const code = url.searchParams.get("code"); const state = url.searchParams.get("state") ?? fallbackState; if (!code) { return { error: "Missing code in callback URL" }; } if (!state) { return { error: "Missing state in callback URL" }; } return { code, state }; } catch { if (!fallbackState) { return { error: "Missing state. Paste the full redirect URL instead of only the code." }; } return { code: trimmed, state: fallbackState }; } } async function promptManualOAuthInput( fallbackState: string, ): Promise { console.log("1. Open the URL above in your browser and complete Google sign-in."); console.log("2. After approving, copy the full redirected localhost URL from the address bar."); console.log("3. Paste it back here.\n"); const callbackInput = await promptOAuthCallbackValue( "Paste the redirect URL (or just the code) here: ", ); const params = parseOAuthCallbackInput(callbackInput, fallbackState); if ("error" in params) { return { type: "failed", error: params.error }; } return exchangeAntigravity(params.code, params.state); } function clampInt(value: number, min: number, max: number): number { if (!Number.isFinite(value)) { return min; } return Math.min(max, Math.max(min, Math.floor(value))); } async function persistAccountPool( results: Array>, replaceAll: boolean = false, ): Promise { if (results.length === 0) { return; } const now = Date.now(); // If replaceAll is true (fresh login), start with empty accounts // Otherwise, load existing accounts and merge const stored = replaceAll ? null : await loadAccounts(); const accounts = stored?.accounts ? [...stored.accounts] : []; const indexByRefreshToken = new Map(); const indexByEmail = new Map(); for (let i = 0; i < accounts.length; i++) { const acc = accounts[i]; if (acc?.refreshToken) { indexByRefreshToken.set(acc.refreshToken, i); } if (acc?.email) { indexByEmail.set(acc.email, i); } } for (const result of results) { const parts = parseRefreshParts(result.refresh); if (!parts.refreshToken) { continue; } // First, check for existing account by email (prevents duplicates when refresh token changes) // Only use email-based deduplication if the new account has an email const existingByEmail = result.email ? indexByEmail.get(result.email) : undefined; const existingByToken = indexByRefreshToken.get(parts.refreshToken); // Prefer email-based match to handle refresh token rotation const existingIndex = existingByEmail ?? existingByToken; if (existingIndex === undefined) { // New account - add it const newIndex = accounts.length; indexByRefreshToken.set(parts.refreshToken, newIndex); if (result.email) { indexByEmail.set(result.email, newIndex); } accounts.push({ email: result.email, refreshToken: parts.refreshToken, projectId: parts.projectId, managedProjectId: parts.managedProjectId, addedAt: now, lastUsed: now, enabled: true, }); continue; } const existing = accounts[existingIndex]; if (!existing) { continue; } // Update existing account (this handles both email match and token match cases) // When email matches but token differs, this effectively replaces the old token const oldToken = existing.refreshToken; accounts[existingIndex] = { ...existing, email: result.email ?? existing.email, refreshToken: parts.refreshToken, projectId: parts.projectId ?? existing.projectId, managedProjectId: parts.managedProjectId ?? existing.managedProjectId, lastUsed: now, }; // Update the token index if the token changed if (oldToken !== parts.refreshToken) { indexByRefreshToken.delete(oldToken); indexByRefreshToken.set(parts.refreshToken, existingIndex); } } if (accounts.length === 0) { return; } // For fresh logins, always start at index 0 const activeIndex = replaceAll ? 0 : (typeof stored?.activeIndex === "number" && Number.isFinite(stored.activeIndex) ? stored.activeIndex : 0); await saveAccounts({ version: 4, accounts, activeIndex: clampInt(activeIndex, 0, accounts.length - 1), activeIndexByFamily: { claude: clampInt(activeIndex, 0, accounts.length - 1), gemini: clampInt(activeIndex, 0, accounts.length - 1), }, }); } function buildAuthSuccessFromStoredAccount(account: { refreshToken: string; projectId?: string; managedProjectId?: string; email?: string; }): Extract { const refresh = formatRefreshParts({ refreshToken: account.refreshToken, projectId: account.projectId, managedProjectId: account.managedProjectId, }); return { type: "success", refresh, access: "", expires: 0, email: account.email, projectId: account.projectId ?? "", }; } function retryAfterMsFromResponse(response: Response, defaultRetryMs: number = 60_000): number { const retryAfterMsHeader = response.headers.get("retry-after-ms"); if (retryAfterMsHeader) { const parsed = Number.parseInt(retryAfterMsHeader, 10); if (!Number.isNaN(parsed) && parsed > 0) { return parsed; } } const retryAfterHeader = response.headers.get("retry-after"); if (retryAfterHeader) { const parsed = Number.parseInt(retryAfterHeader, 10); if (!Number.isNaN(parsed) && parsed > 0) { return parsed * 1000; } } return defaultRetryMs; } /** * Parse Go-style duration strings to milliseconds. * Supports compound durations: "1h16m0.667s", "1.5s", "200ms", "5m30s" * * @param duration - Duration string in Go format * @returns Duration in milliseconds, or null if parsing fails */ function parseDurationToMs(duration: string): number | null { // Handle simple formats first for backwards compatibility const simpleMatch = duration.match(/^(\d+(?:\.\d+)?)(ms|s|m|h)?$/i); if (simpleMatch) { const value = parseFloat(simpleMatch[1]!); const unit = (simpleMatch[2] || "s").toLowerCase(); switch (unit) { case "h": return value * 3600 * 1000; case "m": return value * 60 * 1000; case "s": return value * 1000; case "ms": return value; default: return value * 1000; } } // Parse compound Go-style durations: "1h16m0.667s", "5m30s", etc. const compoundRegex = /(\d+(?:\.\d+)?)(h|m(?!s)|s|ms)/gi; let totalMs = 0; let matchFound = false; let match; while ((match = compoundRegex.exec(duration)) !== null) { matchFound = true; const value = parseFloat(match[1]!); const unit = match[2]!.toLowerCase(); switch (unit) { case "h": totalMs += value * 3600 * 1000; break; case "m": totalMs += value * 60 * 1000; break; case "s": totalMs += value * 1000; break; case "ms": totalMs += value; break; } } return matchFound ? totalMs : null; } interface RateLimitBodyInfo { retryDelayMs: number | null; message?: string; quotaResetTime?: string; reason?: string; } function extractRateLimitBodyInfo(body: unknown): RateLimitBodyInfo { if (!body || typeof body !== "object") { return { retryDelayMs: null }; } const error = (body as { error?: unknown }).error; const message = error && typeof error === "object" ? (error as { message?: string }).message : undefined; const details = error && typeof error === "object" ? (error as { details?: unknown[] }).details : undefined; let reason: string | undefined; if (Array.isArray(details)) { for (const detail of details) { if (!detail || typeof detail !== "object") continue; const type = (detail as { "@type"?: string })["@type"]; if (typeof type === "string" && type.includes("google.rpc.ErrorInfo")) { const detailReason = (detail as { reason?: string }).reason; if (typeof detailReason === "string") { reason = detailReason; break; } } } for (const detail of details) { if (!detail || typeof detail !== "object") continue; const type = (detail as { "@type"?: string })["@type"]; if (typeof type === "string" && type.includes("google.rpc.RetryInfo")) { const retryDelay = (detail as { retryDelay?: string }).retryDelay; if (typeof retryDelay === "string") { const retryDelayMs = parseDurationToMs(retryDelay); if (retryDelayMs !== null) { return { retryDelayMs, message, reason }; } } } } for (const detail of details) { if (!detail || typeof detail !== "object") continue; const metadata = (detail as { metadata?: Record }).metadata; if (metadata && typeof metadata === "object") { const quotaResetDelay = metadata.quotaResetDelay; const quotaResetTime = metadata.quotaResetTimeStamp; if (typeof quotaResetDelay === "string") { const quotaResetDelayMs = parseDurationToMs(quotaResetDelay); if (quotaResetDelayMs !== null) { return { retryDelayMs: quotaResetDelayMs, message, quotaResetTime, reason }; } } } } } if (message) { const afterMatch = message.match(/reset after\s+([0-9hms.]+)/i); const rawDuration = afterMatch?.[1]; if (rawDuration) { const parsed = parseDurationToMs(rawDuration); if (parsed !== null) { return { retryDelayMs: parsed, message, reason }; } } } return { retryDelayMs: null, message, reason }; } async function extractRetryInfoFromBody(response: Response): Promise { try { const text = await response.clone().text(); try { const parsed = JSON.parse(text) as unknown; return extractRateLimitBodyInfo(parsed); } catch { return { retryDelayMs: null }; } } catch { return { retryDelayMs: null }; } } function formatWaitTime(ms: number): string { if (ms < 1000) return `${ms}ms`; const seconds = Math.ceil(ms / 1000); if (seconds < 60) return `${seconds}s`; const minutes = Math.floor(seconds / 60); const remainingSeconds = seconds % 60; if (minutes < 60) { return remainingSeconds > 0 ? `${minutes}m ${remainingSeconds}s` : `${minutes}m`; } const hours = Math.floor(minutes / 60); const remainingMinutes = minutes % 60; return remainingMinutes > 0 ? `${hours}h ${remainingMinutes}m` : `${hours}h`; } // Progressive rate limit retry delays const FIRST_RETRY_DELAY_MS = 1000; // 1s - first 429 quick retry on same account const SWITCH_ACCOUNT_DELAY_MS = 5000; // 5s - delay before switching to another account /** * Rate limit state tracking with time-window deduplication. * * Problem: When multiple subagents hit 429 simultaneously, each would increment * the consecutive counter, causing incorrect exponential backoff (5 concurrent * 429s = 2^5 backoff instead of 2^1). * * Solution: Track per account+quota with deduplication window. Multiple 429s * within RATE_LIMIT_DEDUP_WINDOW_MS are treated as a single event. */ const RATE_LIMIT_DEDUP_WINDOW_MS = 2000; // 2 seconds - concurrent requests within this window are deduplicated const RATE_LIMIT_STATE_RESET_MS = 120_000; // Reset consecutive counter after 2 minutes of no 429s interface RateLimitState { consecutive429: number; lastAt: number; quotaKey: string; // Track which quota this state is for } // Key format: `${accountIndex}:${quotaKey}` for per-account-per-quota tracking const rateLimitStateByAccountQuota = new Map(); // Track empty response retry attempts (ported from LLM-API-Key-Proxy) const emptyResponseAttempts = new Map(); /** * Get rate limit backoff with time-window deduplication. * * @param accountIndex - The account index * @param quotaKey - The quota key (e.g., "gemini-cli", "gemini-antigravity", "claude") * @param serverRetryAfterMs - Server-provided retry delay (if any) * @param maxBackoffMs - Maximum backoff delay in milliseconds (default 60000) * @returns { attempt, delayMs, isDuplicate } - isDuplicate=true if within dedup window */ function getRateLimitBackoff( accountIndex: number, quotaKey: string, serverRetryAfterMs: number | null, maxBackoffMs: number = 60_000 ): { attempt: number; delayMs: number; isDuplicate: boolean } { const now = Date.now(); const stateKey = `${accountIndex}:${quotaKey}`; const previous = rateLimitStateByAccountQuota.get(stateKey); // Check if this is a duplicate 429 within the dedup window if (previous && (now - previous.lastAt < RATE_LIMIT_DEDUP_WINDOW_MS)) { // Same rate limit event from concurrent request - don't increment const baseDelay = serverRetryAfterMs ?? 1000; const backoffDelay = Math.min(baseDelay * Math.pow(2, previous.consecutive429 - 1), maxBackoffMs); return { attempt: previous.consecutive429, delayMs: Math.max(baseDelay, backoffDelay), isDuplicate: true }; } // Check if we should reset (no 429 for 2 minutes) or increment const attempt = previous && (now - previous.lastAt < RATE_LIMIT_STATE_RESET_MS) ? previous.consecutive429 + 1 : 1; rateLimitStateByAccountQuota.set(stateKey, { consecutive429: attempt, lastAt: now, quotaKey }); const baseDelay = serverRetryAfterMs ?? 1000; const backoffDelay = Math.min(baseDelay * Math.pow(2, attempt - 1), maxBackoffMs); return { attempt, delayMs: Math.max(baseDelay, backoffDelay), isDuplicate: false }; } /** * Reset rate limit state for an account+quota combination. * Only resets the specific quota, not all quotas for the account. */ function resetRateLimitState(accountIndex: number, quotaKey: string): void { const stateKey = `${accountIndex}:${quotaKey}`; rateLimitStateByAccountQuota.delete(stateKey); } /** * Reset all rate limit state for an account (all quotas). * Used when account is completely healthy. */ function resetAllRateLimitStateForAccount(accountIndex: number): void { for (const key of rateLimitStateByAccountQuota.keys()) { if (key.startsWith(`${accountIndex}:`)) { rateLimitStateByAccountQuota.delete(key); } } } function headerStyleToQuotaKey(headerStyle: HeaderStyle, family: ModelFamily): string { if (family === "claude") return "claude"; return headerStyle === "antigravity" ? "gemini-antigravity" : "gemini-cli"; } // Track consecutive non-429 failures per account to prevent infinite loops const accountFailureState = new Map(); const MAX_CONSECUTIVE_FAILURES = 5; const FAILURE_COOLDOWN_MS = 30_000; // 30 seconds cooldown after max failures const FAILURE_STATE_RESET_MS = 120_000; // Reset failure count after 2 minutes of no failures function trackAccountFailure(accountIndex: number): { failures: number; shouldCooldown: boolean; cooldownMs: number } { const now = Date.now(); const previous = accountFailureState.get(accountIndex); // Reset if last failure was more than 2 minutes ago const failures = previous && (now - previous.lastFailureAt < FAILURE_STATE_RESET_MS) ? previous.consecutiveFailures + 1 : 1; accountFailureState.set(accountIndex, { consecutiveFailures: failures, lastFailureAt: now }); const shouldCooldown = failures >= MAX_CONSECUTIVE_FAILURES; const cooldownMs = shouldCooldown ? FAILURE_COOLDOWN_MS : 0; return { failures, shouldCooldown, cooldownMs }; } function resetAccountFailureState(accountIndex: number): void { accountFailureState.delete(accountIndex); } /** * Sleep for a given number of milliseconds, respecting an abort signal. */ function sleep(ms: number, signal?: AbortSignal | null): Promise { return new Promise((resolve, reject) => { if (signal?.aborted) { reject(signal.reason instanceof Error ? signal.reason : new Error("Aborted")); return; } const timeout = setTimeout(() => { cleanup(); resolve(); }, ms); const onAbort = () => { cleanup(); reject(signal?.reason instanceof Error ? signal.reason : new Error("Aborted")); }; const cleanup = () => { clearTimeout(timeout); signal?.removeEventListener("abort", onAbort); }; signal?.addEventListener("abort", onAbort, { once: true }); }); } /** * Creates an Antigravity OAuth plugin for a specific provider ID. */ export const createAntigravityPlugin = (providerId: string) => async ( { client, directory }: PluginContext, ): Promise => { // Load configuration from files and environment variables const config = loadConfig(directory); initRuntimeConfig(config); // Cached getAuth function for tool access let cachedGetAuth: GetAuth | null = null; // Initialize debug with config initializeDebug(config); // Initialize structured logger for TUI integration initLogger(client); // Fetch latest Antigravity version from remote API (non-blocking, falls back to hardcoded) await initAntigravityVersion(); // Initialize health tracker for hybrid strategy if (config.health_score) { initHealthTracker({ initial: config.health_score.initial, successReward: config.health_score.success_reward, rateLimitPenalty: config.health_score.rate_limit_penalty, failurePenalty: config.health_score.failure_penalty, recoveryRatePerHour: config.health_score.recovery_rate_per_hour, minUsable: config.health_score.min_usable, maxScore: config.health_score.max_score, }); } // Initialize token tracker for hybrid strategy if (config.token_bucket) { initTokenTracker({ maxTokens: config.token_bucket.max_tokens, regenerationRatePerMinute: config.token_bucket.regeneration_rate_per_minute, initialTokens: config.token_bucket.initial_tokens, }); } // Initialize disk signature cache if keep_thinking is enabled // This integrates with the in-memory cacheSignature/getCachedSignature functions if (config.keep_thinking) { initDiskSignatureCache(config.signature_cache); } // Initialize session recovery hook with full context const sessionRecovery = createSessionRecoveryHook({ client, directory }, config); const updateChecker = createAutoUpdateCheckerHook(client, directory, { showStartupToast: true, autoUpdate: config.auto_update, }); // Event handler for session recovery and updates const eventHandler = async (input: { event: { type: string; properties?: unknown } }) => { // Forward to update checker await updateChecker.event(input); // Track if this is a child session (subagent, background task) // This is used to filter toasts based on toast_scope config if (input.event.type === "session.created") { const props = input.event.properties as { info?: { parentID?: string } } | undefined; if (props?.info?.parentID) { isChildSession = true; childSessionParentID = props.info.parentID; log.debug("child-session-detected", { parentID: props.info.parentID }); } else { // Reset for root sessions - important when plugin instance is reused isChildSession = false; childSessionParentID = undefined; log.debug("root-session-detected", {}); } } // Handle session recovery if (sessionRecovery && input.event.type === "session.error") { const props = input.event.properties as Record | undefined; const sessionID = props?.sessionID as string | undefined; const messageID = props?.messageID as string | undefined; const error = props?.error; if (sessionRecovery.isRecoverableError(error)) { const messageInfo = { id: messageID, role: "assistant" as const, sessionID, error, }; // handleSessionRecovery now does the actual fix (injects tool_result, etc.) const recovered = await sessionRecovery.handleSessionRecovery(messageInfo); // Only send "continue" AFTER successful tool_result_missing recovery // (thinking recoveries already resume inside handleSessionRecovery) if (recovered && sessionID && config.auto_resume) { // For tool_result_missing, we need to send continue after injecting tool_results await client.session.prompt({ path: { id: sessionID }, body: { parts: [{ type: "text", text: config.resume_text }] }, query: { directory }, }).catch(() => {}); // Show success toast (respects toast_scope for child sessions) const successToast = getRecoverySuccessToast(); log.debug("recovery-toast", { ...successToast, isChildSession, toastScope: config.toast_scope }); if (!(config.toast_scope === "root_only" && isChildSession)) { await client.tui.showToast({ body: { title: successToast.title, message: successToast.message, variant: "success", }, }).catch(() => {}); } } } } }; // Create google_search tool with access to auth context const googleSearchTool = tool({ description: "Search the web using Google Search and analyze URLs. Returns real-time information from the internet with source citations. Use this when you need up-to-date information about current events, recent developments, or any topic that may have changed. You can also provide specific URLs to analyze. IMPORTANT: If the user mentions or provides any URLs in their query, you MUST extract those URLs and pass them in the 'urls' parameter for direct analysis.", args: { query: tool.schema.string().describe("The search query or question to answer using web search"), urls: tool.schema.array(tool.schema.string()).optional().describe("List of specific URLs to fetch and analyze. IMPORTANT: Always extract and include any URLs mentioned by the user in their query here."), thinking: tool.schema.boolean().optional().default(true).describe("Enable deep thinking for more thorough analysis (default: true)"), }, async execute(args, ctx) { log.debug("Google Search tool called", { query: args.query, urlCount: args.urls?.length ?? 0 }); // Get current auth context const auth = cachedGetAuth ? await cachedGetAuth() : null; if (!auth || !isOAuthAuth(auth)) { return "Error: Not authenticated with Antigravity. Please run `opencode auth login` to authenticate."; } // Get access token and project ID const parts = parseRefreshParts(auth.refresh); const projectId = parts.managedProjectId || parts.projectId || "unknown"; // Ensure we have a valid access token let accessToken = auth.access; if (!accessToken || accessTokenExpired(auth)) { try { const refreshed = await refreshAccessToken(auth, client, providerId); accessToken = refreshed?.access; } catch (error) { return `Error: Failed to refresh access token: ${error instanceof Error ? error.message : String(error)}`; } } if (!accessToken) { return "Error: No valid access token available. Please run `opencode auth login` to re-authenticate."; } return executeSearch( { query: args.query, urls: args.urls, thinking: args.thinking, }, accessToken, projectId, ctx.abort, ); }, }); return { event: eventHandler, tool: { google_search: googleSearchTool, }, auth: { provider: providerId, loader: async (getAuth: GetAuth, provider: Provider): Promise> => { // Cache getAuth for tool access cachedGetAuth = getAuth; const auth = await getAuth(); // If OpenCode has no valid OAuth auth, clear any stale account storage if (!isOAuthAuth(auth)) { try { await clearAccounts(); } catch { // ignore } return {}; } // Validate that stored accounts are in sync with OpenCode's auth // If OpenCode's refresh token doesn't match any stored account, clear stale storage const authParts = parseRefreshParts(auth.refresh); const storedAccounts = await loadAccounts(); // Note: AccountManager now ensures the current auth is always included in accounts const accountManager = await AccountManager.loadFromDisk(auth); activeAccountManager = accountManager; if (accountManager.getAccountCount() > 0) { accountManager.requestSaveToDisk(); } // Initialize proactive token refresh queue (ported from LLM-API-Key-Proxy) let refreshQueue: ProactiveRefreshQueue | null = null; if (config.proactive_token_refresh && accountManager.getAccountCount() > 0) { refreshQueue = createProactiveRefreshQueue(client, providerId, { enabled: config.proactive_token_refresh, bufferSeconds: config.proactive_refresh_buffer_seconds, checkIntervalSeconds: config.proactive_refresh_check_interval_seconds, }); refreshQueue.setAccountManager(accountManager); refreshQueue.start(); } if (isDebugEnabled()) { const logPath = getLogFilePath(); if (logPath) { try { await client.tui.showToast({ body: { message: `Debug log: ${logPath}`, variant: "info" }, }); } catch { // TUI may not be available } } } if (provider.models) { for (const model of Object.values(provider.models)) { if (model) { model.cost = { input: 0, output: 0 }; } } } return { apiKey: "", async fetch(input, init) { if (!isGenerativeLanguageRequest(input)) { return fetch(input, init); } const latestAuth = await getAuth(); if (!isOAuthAuth(latestAuth)) { return fetch(input, init); } if (accountManager.getAccountCount() === 0) { throw new Error("No Antigravity accounts configured. Run `opencode auth login`."); } const urlString = toUrlString(input); const family = getModelFamilyFromUrl(urlString); const model = extractModelFromUrl(urlString); const debugLines: string[] = []; const pushDebug = (line: string) => { if (!isDebugEnabled()) return; debugLines.push(line); }; pushDebug(`request=${urlString}`); type FailureContext = { response: Response; streaming: boolean; debugContext: ReturnType; requestedModel?: string; projectId?: string; endpoint?: string; effectiveModel?: string; sessionId?: string; toolDebugMissing?: number; toolDebugSummary?: string; toolDebugPayload?: string; }; let lastFailure: FailureContext | null = null; let lastError: Error | null = null; const abortSignal = init?.signal ?? undefined; // Helper to check if request was aborted const checkAborted = () => { if (abortSignal?.aborted) { throw abortSignal.reason instanceof Error ? abortSignal.reason : new Error("Aborted"); } }; // Use while(true) loop to handle rate limits with backoff // This ensures we wait and retry when all accounts are rate-limited const quietMode = config.quiet_mode; const toastScope = config.toast_scope; // Helper to show toast without blocking on abort (respects quiet_mode and toast_scope) const showToast = async (message: string, variant: "info" | "warning" | "success" | "error") => { // Always log to debug regardless of toast filtering log.debug("toast", { message, variant, isChildSession, toastScope }); if (quietMode) return; if (abortSignal?.aborted) return; // Filter toasts for child sessions when toast_scope is "root_only" if (toastScope === "root_only" && isChildSession) { log.debug("toast-suppressed-child-session", { message, variant, parentID: childSessionParentID }); return; } if (variant === "warning" && message.toLowerCase().includes("rate")) { if (!shouldShowRateLimitToast(message)) { return; } } try { await client.tui.showToast({ body: { message, variant }, }); } catch { // TUI may not be available } }; const hasOtherAccountWithAntigravity = (currentAccount: any): boolean => { if (family !== "gemini") return false; // Use AccountManager method which properly checks for disabled/cooling-down accounts return accountManager.hasOtherAccountWithAntigravityAvailable(currentAccount.index, family, model); }; while (true) { // Check for abort at the start of each iteration checkAborted(); const accountCount = accountManager.getAccountCount(); const routingDecision = resolveHeaderRoutingDecision(urlString, family, config); const { cliFirst, preferredHeaderStyle, explicitQuota, allowQuotaFallback, } = routingDecision; if (accountCount === 0) { throw new Error("No Antigravity accounts available. Run `opencode auth login`."); } const softQuotaCacheTtlMs = computeSoftQuotaCacheTtlMs( config.soft_quota_cache_ttl_minutes, config.quota_refresh_interval_minutes, ); let account = accountManager.getCurrentOrNextForFamily( family, model, config.account_selection_strategy, preferredHeaderStyle, config.pid_offset_enabled, config.soft_quota_threshold_percent, softQuotaCacheTtlMs, ); if (!account && allowQuotaFallback) { const alternateHeaderStyle: HeaderStyle = preferredHeaderStyle === "antigravity" ? "gemini-cli" : "antigravity"; account = accountManager.getCurrentOrNextForFamily( family, model, config.account_selection_strategy, alternateHeaderStyle, config.pid_offset_enabled, config.soft_quota_threshold_percent, softQuotaCacheTtlMs, ); if (account) { pushDebug( `selected-by-fallback idx=${account.index} preferred=${preferredHeaderStyle} alternate=${alternateHeaderStyle}`, ); } } if (!account) { if (accountManager.areAllAccountsOverSoftQuota(family, config.soft_quota_threshold_percent, softQuotaCacheTtlMs, model)) { const threshold = config.soft_quota_threshold_percent; const softQuotaWaitMs = accountManager.getMinWaitTimeForSoftQuota(family, threshold, softQuotaCacheTtlMs, model); const maxWaitMs = (config.max_rate_limit_wait_seconds ?? 300) * 1000; if (softQuotaWaitMs === null || (maxWaitMs > 0 && softQuotaWaitMs > maxWaitMs)) { const waitTimeFormatted = softQuotaWaitMs ? formatWaitTime(softQuotaWaitMs) : "unknown"; await showToast( `All accounts over ${threshold}% quota threshold. Resets in ${waitTimeFormatted}.`, "error" ); throw new Error( `Quota protection: All ${accountCount} account(s) are over ${threshold}% usage for ${family}. ` + `Quota resets in ${waitTimeFormatted}. ` + `Add more accounts, wait for quota reset, or set soft_quota_threshold_percent: 100 to disable.` ); } const waitSecValue = Math.max(1, Math.ceil(softQuotaWaitMs / 1000)); pushDebug(`all-over-soft-quota family=${family} accounts=${accountCount} waitMs=${softQuotaWaitMs}`); if (!softQuotaToastShown) { await showToast(`All ${accountCount} account(s) over ${threshold}% quota. Waiting ${formatWaitTime(softQuotaWaitMs)}...`, "warning"); softQuotaToastShown = true; } await sleep(softQuotaWaitMs, abortSignal); continue; } const strictWait = !allowQuotaFallback; // All accounts are rate-limited - wait and retry const waitMs = accountManager.getMinWaitTimeForFamily( family, model, preferredHeaderStyle, strictWait, ) || 60_000; const waitSecValue = Math.max(1, Math.ceil(waitMs / 1000)); pushDebug(`all-rate-limited family=${family} accounts=${accountCount} waitMs=${waitMs}`); if (isDebugEnabled()) { logAccountContext("All accounts rate-limited", { index: -1, family, totalAccounts: accountCount, }); logRateLimitSnapshot(family, accountManager.getAccountsSnapshot()); } // If wait time exceeds max threshold, return error immediately instead of hanging // 0 means disabled (wait indefinitely) const maxWaitMs = (config.max_rate_limit_wait_seconds ?? 300) * 1000; if (maxWaitMs > 0 && waitMs > maxWaitMs) { const waitTimeFormatted = formatWaitTime(waitMs); await showToast( `Rate limited for ${waitTimeFormatted}. Try again later or add another account.`, "error" ); // Return a proper rate limit error response throw new Error( `All ${accountCount} account(s) rate-limited for ${family}. ` + `Quota resets in ${waitTimeFormatted}. ` + `Add more accounts with \`opencode auth login\` or wait and retry.` ); } if (!rateLimitToastShown) { await showToast(`All ${accountCount} account(s) rate-limited for ${family}. Waiting ${waitSecValue}s...`, "warning"); rateLimitToastShown = true; } // Wait for the rate-limit cooldown to expire, then retry await sleep(waitMs, abortSignal); continue; } // Account is available - reset the toast flag resetAllAccountsBlockedToasts(); pushDebug( `selected idx=${account.index} email=${account.email ?? ""} family=${family} accounts=${accountCount} strategy=${config.account_selection_strategy}`, ); if (isDebugEnabled()) { logAccountContext("Selected", { index: account.index, email: account.email, family, totalAccounts: accountCount, rateLimitState: account.rateLimitResetTimes, }); } // Show toast when switching to a different account (debounced, quiet_mode handled by showToast) if (accountCount > 1 && accountManager.shouldShowAccountToast(account.index)) { const accountLabel = account.email || `Account ${account.index + 1}`; // Calculate position among enabled accounts (not absolute index) const enabledAccounts = accountManager.getEnabledAccounts(); const enabledPosition = enabledAccounts.findIndex(a => a.index === account.index) + 1; await showToast( `Using ${accountLabel} (${enabledPosition}/${accountCount})`, "info" ); accountManager.markToastShown(account.index); } accountManager.requestSaveToDisk(); let authRecord = accountManager.toAuthDetails(account); if (accessTokenExpired(authRecord)) { try { const refreshed = await refreshAccessToken(authRecord, client, providerId); if (!refreshed) { const { failures, shouldCooldown, cooldownMs } = trackAccountFailure(account.index); getHealthTracker().recordFailure(account.index); lastError = new Error("Antigravity token refresh failed"); if (shouldCooldown) { accountManager.markAccountCoolingDown(account, cooldownMs, "auth-failure"); accountManager.markRateLimited(account, cooldownMs, family, "antigravity", model); pushDebug(`token-refresh-failed: cooldown ${cooldownMs}ms after ${failures} failures`); } continue; } resetAccountFailureState(account.index); accountManager.updateFromAuth(account, refreshed); authRecord = refreshed; try { await accountManager.saveToDisk(); } catch (error) { log.error("Failed to persist refreshed auth", { error: String(error) }); } } catch (error) { if (error instanceof AntigravityTokenRefreshError && error.code === "invalid_grant") { const removed = accountManager.removeAccount(account); if (removed) { log.warn("Removed revoked account from pool - reauthenticate via `opencode auth login`"); try { await accountManager.saveToDisk(); } catch (persistError) { log.error("Failed to persist revoked account removal", { error: String(persistError) }); } } if (accountManager.getAccountCount() === 0) { try { await client.auth.set({ path: { id: providerId }, body: { type: "oauth", refresh: "", access: "", expires: 0 }, }); } catch (storeError) { log.error("Failed to clear stored Antigravity OAuth credentials", { error: String(storeError) }); } throw new Error( "All Antigravity accounts have invalid refresh tokens. Run `opencode auth login` and reauthenticate.", ); } lastError = error; continue; } const { failures, shouldCooldown, cooldownMs } = trackAccountFailure(account.index); getHealthTracker().recordFailure(account.index); lastError = error instanceof Error ? error : new Error(String(error)); if (shouldCooldown) { accountManager.markAccountCoolingDown(account, cooldownMs, "auth-failure"); accountManager.markRateLimited(account, cooldownMs, family, "antigravity", model); pushDebug(`token-refresh-error: cooldown ${cooldownMs}ms after ${failures} failures`); } continue; } } const accessToken = authRecord.access; if (!accessToken) { lastError = new Error("Missing access token"); if (accountCount <= 1) { throw lastError; } continue; } let projectContext: ProjectContextResult; try { projectContext = await ensureProjectContext(authRecord); resetAccountFailureState(account.index); } catch (error) { const { failures, shouldCooldown, cooldownMs } = trackAccountFailure(account.index); getHealthTracker().recordFailure(account.index); lastError = error instanceof Error ? error : new Error(String(error)); if (shouldCooldown) { accountManager.markAccountCoolingDown(account, cooldownMs, "project-error"); accountManager.markRateLimited(account, cooldownMs, family, "antigravity", model); pushDebug(`project-context-error: cooldown ${cooldownMs}ms after ${failures} failures`); } continue; } if (projectContext.auth.refresh !== authRecord.refresh || projectContext.auth.access !== authRecord.access) { accountManager.updateFromAuth(account, projectContext.auth); authRecord = projectContext.auth; try { await accountManager.saveToDisk(); } catch (error) { log.error("Failed to persist project context", { error: String(error) }); } } const runThinkingWarmup = async ( prepared: ReturnType, projectId: string, ): Promise => { if (!prepared.needsSignedThinkingWarmup || !prepared.sessionId) { return; } if (!trackWarmupAttempt(prepared.sessionId)) { return; } const warmupBody = buildThinkingWarmupBody( typeof prepared.init.body === "string" ? prepared.init.body : undefined, Boolean(prepared.effectiveModel?.toLowerCase().includes("claude") && prepared.effectiveModel?.toLowerCase().includes("thinking")), ); if (!warmupBody) { return; } const warmupUrl = toWarmupStreamUrl(prepared.request); const warmupHeaders = new Headers(prepared.init.headers ?? {}); warmupHeaders.set("accept", "text/event-stream"); const warmupInit: RequestInit = { ...prepared.init, method: prepared.init.method ?? "POST", headers: warmupHeaders, body: warmupBody, }; const warmupDebugContext = startAntigravityDebugRequest({ originalUrl: warmupUrl, resolvedUrl: warmupUrl, method: warmupInit.method, headers: warmupHeaders, body: warmupBody, streaming: true, projectId, }); try { pushDebug("thinking-warmup: start"); const warmupResponse = await fetch(warmupUrl, warmupInit); const transformed = await transformAntigravityResponse( warmupResponse, true, warmupDebugContext, prepared.requestedModel, projectId, warmupUrl, prepared.effectiveModel, prepared.sessionId, ); await transformed.text(); markWarmupSuccess(prepared.sessionId); pushDebug("thinking-warmup: done"); } catch (error) { clearWarmupAttempt(prepared.sessionId); pushDebug( `thinking-warmup: failed ${error instanceof Error ? error.message : String(error)}`, ); } }; // Try endpoint fallbacks with single header style based on model suffix let shouldSwitchAccount = false; // Determine header style from model suffix: // - Models with antigravity- prefix -> use Antigravity quota // - Gemini models without explicit prefix -> follow cli_first // - Claude models -> always use Antigravity let headerStyle = preferredHeaderStyle; pushDebug(`headerStyle=${headerStyle} explicit=${explicitQuota}`); if (account.fingerprint) { pushDebug(`fingerprint: quotaUser=${account.fingerprint.quotaUser} deviceId=${account.fingerprint.deviceId.slice(0, 8)}...`); } // Check if this header style is rate-limited for this account if (accountManager.isRateLimitedForHeaderStyle(account, family, headerStyle, model)) { // Antigravity-first fallback: exhaust antigravity across ALL accounts before gemini-cli if (allowQuotaFallback && family === "gemini" && headerStyle === "antigravity") { // Check if ANY other account has antigravity available if (accountManager.hasOtherAccountWithAntigravityAvailable(account.index, family, model)) { // Switch to another account with antigravity (preserve antigravity priority) pushDebug(`antigravity rate-limited on account ${account.index}, but available on other accounts. Switching.`); shouldSwitchAccount = true; } else { // All accounts exhausted antigravity - fall back to gemini-cli on this account const alternateStyle = accountManager.getAvailableHeaderStyle(account, family, model); const fallbackStyle = resolveQuotaFallbackHeaderStyle({ family, headerStyle, alternateStyle, }); if (fallbackStyle) { await showToast( `Antigravity quota exhausted on all accounts. Using Gemini CLI quota.`, "warning" ); headerStyle = fallbackStyle; pushDebug(`all-accounts antigravity exhausted, quota fallback: ${headerStyle}`); } else { shouldSwitchAccount = true; } } } else if (allowQuotaFallback && family === "gemini") { // gemini-cli rate-limited - try alternate style (antigravity) on same account const alternateStyle = accountManager.getAvailableHeaderStyle(account, family, model); const fallbackStyle = resolveQuotaFallbackHeaderStyle({ family, headerStyle, alternateStyle, }); if (fallbackStyle) { const quotaName = headerStyle === "gemini-cli" ? "Gemini CLI" : "Antigravity"; const altQuotaName = fallbackStyle === "gemini-cli" ? "Gemini CLI" : "Antigravity"; await showToast( `${quotaName} quota exhausted, using ${altQuotaName} quota`, "warning" ); headerStyle = fallbackStyle; pushDebug(`quota fallback: ${headerStyle}`); } else { shouldSwitchAccount = true; } } else { shouldSwitchAccount = true; } } while (!shouldSwitchAccount) { // Flag to force thinking recovery on retry after API error let forceThinkingRecovery = false; // Track if token was consumed (for hybrid strategy refund on error) let tokenConsumed = false; // Track capacity retries per endpoint to prevent infinite loops let capacityRetryCount = 0; let lastEndpointIndex = -1; for (let i = 0; i < ANTIGRAVITY_ENDPOINT_FALLBACKS.length; i++) { // Reset capacity retry counter when switching to a new endpoint if (i !== lastEndpointIndex) { capacityRetryCount = 0; lastEndpointIndex = i; } const currentEndpoint = ANTIGRAVITY_ENDPOINT_FALLBACKS[i]; // Skip sandbox endpoints for Gemini CLI models - they only work with Antigravity quota // Gemini CLI models must use production endpoint (cloudcode-pa.googleapis.com) if (headerStyle === "gemini-cli" && currentEndpoint !== ANTIGRAVITY_ENDPOINT_PROD) { pushDebug(`Skipping sandbox endpoint ${currentEndpoint} for gemini-cli headerStyle`); continue; } try { const prepared = prepareAntigravityRequest( input, init, accessToken, projectContext.effectiveProjectId, currentEndpoint, headerStyle, forceThinkingRecovery, { claudeToolHardening: config.claude_tool_hardening, claudePromptAutoCaching: config.claude_prompt_auto_caching, fingerprint: account.fingerprint, }, ); const originalUrl = toUrlString(input); const resolvedUrl = toUrlString(prepared.request); pushDebug(`endpoint=${currentEndpoint}`); pushDebug(`resolved=${resolvedUrl}`); const debugContext = startAntigravityDebugRequest({ originalUrl, resolvedUrl, method: prepared.init.method, headers: prepared.init.headers, body: prepared.init.body, streaming: prepared.streaming, projectId: projectContext.effectiveProjectId, }); const createFailureContext = (failureResponse: Response): FailureContext => ({ response: failureResponse, streaming: prepared.streaming, debugContext, requestedModel: prepared.requestedModel, projectId: prepared.projectId, endpoint: prepared.endpoint, effectiveModel: prepared.effectiveModel, sessionId: prepared.sessionId, toolDebugMissing: prepared.toolDebugMissing, toolDebugSummary: prepared.toolDebugSummary, toolDebugPayload: prepared.toolDebugPayload, }); await runThinkingWarmup(prepared, projectContext.effectiveProjectId); if (config.request_jitter_max_ms > 0) { const jitterMs = Math.floor(Math.random() * config.request_jitter_max_ms); if (jitterMs > 0) { await sleep(jitterMs, abortSignal); } } // Consume token for hybrid strategy // Refunded later if request fails (429 or network error) if (config.account_selection_strategy === 'hybrid') { tokenConsumed = getTokenTracker().consume(account.index); } const response = await fetch(prepared.request, prepared.init); pushDebug(`status=${response.status} ${response.statusText}`); // Handle 429 rate limit (or Service Overloaded) with improved logic if (response.status === 429 || response.status === 503 || response.status === 529) { // Refund token on rate limit if (tokenConsumed) { getTokenTracker().refund(account.index); tokenConsumed = false; } const defaultRetryMs = (config.default_retry_after_seconds ?? 60) * 1000; const maxBackoffMs = (config.max_backoff_seconds ?? 60) * 1000; const headerRetryMs = retryAfterMsFromResponse(response, defaultRetryMs); const bodyInfo = await extractRetryInfoFromBody(response); const serverRetryMs = bodyInfo.retryDelayMs ?? headerRetryMs; // [Enhanced Parsing] Pass status to handling logic const rateLimitReason = parseRateLimitReason(bodyInfo.reason, bodyInfo.message, response.status); // STRATEGY 1: CAPACITY / SERVER ERROR (Transient) // Goal: Wait and Retry SAME Account. DO NOT LOCK. // We handle this FIRST to avoid calling getRateLimitBackoff() and polluting the global rate limit state for transient errors. if (rateLimitReason === "MODEL_CAPACITY_EXHAUSTED" || rateLimitReason === "SERVER_ERROR") { // Exponential backoff with jitter for capacity errors: 1s → 2s → 4s → 8s (max) // Matches Antigravity-Manager's ExponentialBackoff(1s, 8s) const baseDelayMs = 1000; const maxDelayMs = 8000; const exponentialDelay = Math.min(baseDelayMs * Math.pow(2, capacityRetryCount), maxDelayMs); // Add ±10% jitter to prevent thundering herd const jitter = exponentialDelay * (0.9 + Math.random() * 0.2); const waitMs = Math.round(jitter); const waitSec = Math.round(waitMs / 1000); pushDebug(`Server busy (${rateLimitReason}) on account ${account.index}, exponential backoff ${waitMs}ms (attempt ${capacityRetryCount + 1})`); await showToast( `⏳ Server busy (${response.status}). Retrying in ${waitSec}s...`, "warning", ); await sleep(waitMs, abortSignal); // CRITICAL FIX: Decrement i so that the loop 'continue' retries the SAME endpoint index // (i++ in the loop will bring it back to the current index) // But limit retries to prevent infinite loops (Greptile feedback) if (capacityRetryCount < 3) { capacityRetryCount++; i -= 1; continue; } else { pushDebug(`Max capacity retries (3) exhausted for endpoint ${currentEndpoint}, regenerating fingerprint...`); // Regenerate fingerprint to get fresh device identity before trying next endpoint const newFingerprint = accountManager.regenerateAccountFingerprint(account.index); if (newFingerprint) { pushDebug(`Fingerprint regenerated for account ${account.index}`); } continue; } } // STRATEGY 2: RATE LIMIT EXCEEDED (RPM) / QUOTA EXHAUSTED / UNKNOWN // Goal: Lock and Rotate (Standard Logic) // Only now do we call getRateLimitBackoff, which increments the global failure tracker const quotaKey = headerStyleToQuotaKey(headerStyle, family); const { attempt, delayMs, isDuplicate } = getRateLimitBackoff(account.index, quotaKey, serverRetryMs); // Calculate potential backoffs const smartBackoffMs = calculateBackoffMs(rateLimitReason, account.consecutiveFailures ?? 0, serverRetryMs); const effectiveDelayMs = Math.max(delayMs, smartBackoffMs); pushDebug( `429 idx=${account.index} email=${account.email ?? ""} family=${family} delayMs=${effectiveDelayMs} attempt=${attempt} reason=${rateLimitReason}`, ); if (bodyInfo.message) { pushDebug(`429 message=${bodyInfo.message}`); } if (bodyInfo.quotaResetTime) { pushDebug(`429 quotaResetTime=${bodyInfo.quotaResetTime}`); } if (bodyInfo.reason) { pushDebug(`429 reason=${bodyInfo.reason}`); } logRateLimitEvent( account.index, account.email, family, response.status, effectiveDelayMs, bodyInfo, ); await logResponseBody(debugContext, response, 429); getHealthTracker().recordRateLimit(account.index); const accountLabel = account.email || `Account ${account.index + 1}`; // Progressive retry for standard 429s: 1st 429 → 1s then switch (if enabled) or retry same if (attempt === 1 && rateLimitReason !== "QUOTA_EXHAUSTED") { await showToast(`Rate limited. Quick retry in 1s...`, "warning"); await sleep(FIRST_RETRY_DELAY_MS, abortSignal); // CacheFirst mode: wait for same account if within threshold (preserves prompt cache) if (config.scheduling_mode === 'cache_first') { const maxCacheFirstWaitMs = config.max_cache_first_wait_seconds * 1000; // effectiveDelayMs is the backoff calculated for this account if (effectiveDelayMs <= maxCacheFirstWaitMs) { pushDebug(`cache_first: waiting ${effectiveDelayMs}ms for same account to recover`); await showToast(`⏳ Waiting ${Math.ceil(effectiveDelayMs / 1000)}s for same account (prompt cache preserved)...`, "info"); accountManager.markRateLimitedWithReason(account, family, headerStyle, model, rateLimitReason, serverRetryMs); await sleep(effectiveDelayMs, abortSignal); // Retry same endpoint after wait i -= 1; continue; } // Wait time exceeds threshold, fall through to switch pushDebug(`cache_first: wait ${effectiveDelayMs}ms exceeds max ${maxCacheFirstWaitMs}ms, switching account`); } if (config.switch_on_first_rate_limit && accountCount > 1) { accountManager.markRateLimitedWithReason(account, family, headerStyle, model, rateLimitReason, serverRetryMs, config.failure_ttl_seconds * 1000); shouldSwitchAccount = true; break; } // Same endpoint retry for first RPM hit i -= 1; continue; } accountManager.markRateLimitedWithReason(account, family, headerStyle, model, rateLimitReason, serverRetryMs, config.failure_ttl_seconds * 1000); accountManager.requestSaveToDisk(); // For Gemini, preserve preferred quota across accounts before fallback if (family === "gemini") { if (headerStyle === "antigravity") { // Check if any other account has Antigravity quota for this model if (hasOtherAccountWithAntigravity(account)) { pushDebug(`antigravity exhausted on account ${account.index}, but available on others. Switching account.`); await showToast(`Rate limited again. Switching account in 5s...`, "warning"); await sleep(SWITCH_ACCOUNT_DELAY_MS, abortSignal); shouldSwitchAccount = true; break; } // All accounts exhausted for Antigravity on THIS model. // Before falling back to gemini-cli, check if it's the last option (automatic fallback) if (allowQuotaFallback) { const alternateStyle = accountManager.getAvailableHeaderStyle(account, family, model); const fallbackStyle = resolveQuotaFallbackHeaderStyle({ family, headerStyle, alternateStyle, }); if (fallbackStyle) { const safeModelName = model || "this model"; await showToast( `Antigravity quota exhausted for ${safeModelName}. Switching to Gemini CLI quota...`, "warning" ); headerStyle = fallbackStyle; pushDebug(`quota fallback: ${headerStyle}`); continue; } } } else if (headerStyle === "gemini-cli") { if (allowQuotaFallback) { const alternateStyle = accountManager.getAvailableHeaderStyle(account, family, model); const fallbackStyle = resolveQuotaFallbackHeaderStyle({ family, headerStyle, alternateStyle, }); if (fallbackStyle) { const safeModelName = model || "this model"; await showToast( `Gemini CLI quota exhausted for ${safeModelName}. Switching to Antigravity quota...`, "warning" ); headerStyle = fallbackStyle; pushDebug(`quota fallback: ${headerStyle}`); continue; } } } } const quotaName = headerStyle === "antigravity" ? "Antigravity" : "Gemini CLI"; if (accountCount > 1) { const quotaMsg = bodyInfo.quotaResetTime ? ` (quota resets ${bodyInfo.quotaResetTime})` : ``; await showToast(`Rate limited again. Switching account in 5s...${quotaMsg}`, "warning"); await sleep(SWITCH_ACCOUNT_DELAY_MS, abortSignal); } else { // Single account: exponential backoff (1s, 2s, 4s, 8s... max 60s) const expBackoffMs = Math.min(FIRST_RETRY_DELAY_MS * Math.pow(2, attempt - 1), 60000); const expBackoffFormatted = expBackoffMs >= 1000 ? `${Math.round(expBackoffMs / 1000)}s` : `${expBackoffMs}ms`; await showToast(`Rate limited. Retrying in ${expBackoffFormatted} (attempt ${attempt})...`, "warning"); await sleep(expBackoffMs, abortSignal); } lastFailure = createFailureContext(response); shouldSwitchAccount = true; break; } // Success - reset rate limit backoff state for this quota const quotaKey = headerStyleToQuotaKey(headerStyle, family); resetRateLimitState(account.index, quotaKey); resetAccountFailureState(account.index); if (response.status === 403) { const errorBodyText = await response.clone().text().catch(() => ""); const extracted = extractVerificationErrorDetails(errorBodyText); if (extracted.validationRequired) { const verificationReason = extracted.message ?? "Google requires account verification."; const cooldownMs = 10 * 60 * 1000; accountManager.markAccountVerificationRequired(account.index, verificationReason, extracted.verifyUrl); accountManager.markAccountCoolingDown(account, cooldownMs, "validation-required"); accountManager.markRateLimited(account, cooldownMs, family, headerStyle, model); const label = account.email || `Account ${account.index + 1}`; if (accountManager.shouldShowAccountToast(account.index, 60000)) { await showToast( `⚠ ${label} needs verification. Run 'opencode auth login' and use Verify accounts.`, "warning", ); accountManager.markToastShown(account.index); } pushDebug(`verification-required: disabled account ${account.index}`); getHealthTracker().recordFailure(account.index); lastFailure = createFailureContext(response); shouldSwitchAccount = true; break; } } const shouldRetryEndpoint = ( response.status === 403 || response.status === 404 || response.status >= 500 ); if (shouldRetryEndpoint && i < ANTIGRAVITY_ENDPOINT_FALLBACKS.length - 1) { await logResponseBody(debugContext, response, response.status); lastFailure = createFailureContext(response); continue; } // Success or non-retryable error - return the response if (response.ok) { account.consecutiveFailures = 0; getHealthTracker().recordSuccess(account.index); accountManager.markAccountUsed(account.index); void triggerAsyncQuotaRefreshForAccount( accountManager, account.index, client, providerId, config.quota_refresh_interval_minutes, ); } logAntigravityDebugResponse(debugContext, response, { note: response.ok ? "Success" : `Error ${response.status}`, }); if (response.ok && !prepared.streaming) { await logResponseBody(debugContext, response, response.status); } if (!response.ok) { await logResponseBody(debugContext, response, response.status); // Handle 400 "Prompt too long" with synthetic response to avoid session lock if (response.status === 400) { const cloned = response.clone(); const bodyText = await cloned.text(); if (bodyText.includes("Prompt is too long") || bodyText.includes("prompt_too_long")) { await showToast( "Context too long - use /compact to reduce size", "warning" ); const errorMessage = `[Antigravity Error] Context is too long for this model.\n\nPlease use /compact to reduce context size, then retry your request.\n\nAlternatively, you can:\n- Use /clear to start fresh\n- Use /undo to remove recent messages\n- Switch to a model with larger context window`; return createSyntheticErrorResponse(errorMessage, prepared.requestedModel); } } } // Empty response retry logic (ported from LLM-API-Key-Proxy) // For non-streaming responses, check if the response body is empty // and retry if so (up to config.empty_response_max_attempts times) if (response.ok && !prepared.streaming) { const maxAttempts = config.empty_response_max_attempts ?? 4; const retryDelayMs = config.empty_response_retry_delay_ms ?? 2000; // Clone to check body without consuming original const clonedForCheck = response.clone(); const bodyText = await clonedForCheck.text(); if (isEmptyResponseBody(bodyText)) { // Track empty response attempts per request const emptyAttemptKey = `${prepared.sessionId ?? "none"}:${prepared.effectiveModel ?? "unknown"}`; const currentAttempts = (emptyResponseAttempts.get(emptyAttemptKey) ?? 0) + 1; emptyResponseAttempts.set(emptyAttemptKey, currentAttempts); pushDebug(`empty-response: attempt ${currentAttempts}/${maxAttempts}`); if (currentAttempts < maxAttempts) { await showToast( `Empty response received. Retrying (${currentAttempts}/${maxAttempts})...`, "warning" ); await sleep(retryDelayMs, abortSignal); continue; // Retry the endpoint loop } // Clean up and throw after max attempts emptyResponseAttempts.delete(emptyAttemptKey); throw new EmptyResponseError( "antigravity", prepared.effectiveModel ?? "unknown", currentAttempts, ); } // Clean up successful attempt tracking const emptyAttemptKeyClean = `${prepared.sessionId ?? "none"}:${prepared.effectiveModel ?? "unknown"}`; emptyResponseAttempts.delete(emptyAttemptKeyClean); } const transformedResponse = await transformAntigravityResponse( response, prepared.streaming, debugContext, prepared.requestedModel, prepared.projectId, prepared.endpoint, prepared.effectiveModel, prepared.sessionId, prepared.toolDebugMissing, prepared.toolDebugSummary, prepared.toolDebugPayload, debugLines, ); // Check for context errors and show appropriate toast const contextError = transformedResponse.headers.get("x-antigravity-context-error"); if (contextError) { if (contextError === "prompt_too_long") { await showToast( "Context too long - use /compact to reduce size, or trim your request", "warning" ); } else if (contextError === "tool_pairing") { await showToast( "Tool call/result mismatch - use /compact to fix, or /undo last message", "warning" ); } } return transformedResponse; } catch (error) { // Refund token on network/API error (only if consumed) if (tokenConsumed) { getTokenTracker().refund(account.index); tokenConsumed = false; } // Handle recoverable thinking errors - retry with forced recovery if (error instanceof Error && error.message === "THINKING_RECOVERY_NEEDED") { // Only retry once with forced recovery to avoid infinite loops if (!forceThinkingRecovery) { pushDebug("thinking-recovery: API error detected, retrying with forced recovery"); forceThinkingRecovery = true; i = -1; // Will become 0 after loop increment, restart endpoint loop continue; } // Already tried with forced recovery, give up and return error const recoveryError = error as any; const originalError = recoveryError.originalError || { error: { message: "Thinking recovery triggered" } }; const recoveryMessage = `${originalError.error?.message || "Session recovery failed"}\n\n[RECOVERY] Thinking block corruption could not be resolved. Try starting a new session.`; return new Response(JSON.stringify({ type: "error", error: { type: "unrecoverable_error", message: recoveryMessage } }), { status: 400, headers: { "Content-Type": "application/json" } }); } if (i < ANTIGRAVITY_ENDPOINT_FALLBACKS.length - 1) { lastError = error instanceof Error ? error : new Error(String(error)); continue; } // All endpoints failed for this account - track failure and try next account const { failures, shouldCooldown, cooldownMs } = trackAccountFailure(account.index); lastError = error instanceof Error ? error : new Error(String(error)); if (shouldCooldown) { accountManager.markAccountCoolingDown(account, cooldownMs, "network-error"); accountManager.markRateLimited(account, cooldownMs, family, headerStyle, model); pushDebug(`endpoint-error: cooldown ${cooldownMs}ms after ${failures} failures`); } shouldSwitchAccount = true; break; } } } // end headerStyleLoop if (shouldSwitchAccount) { // Avoid tight retry loops when there's only one account. if (accountCount <= 1) { if (lastFailure) { return transformAntigravityResponse( lastFailure.response, lastFailure.streaming, lastFailure.debugContext, lastFailure.requestedModel, lastFailure.projectId, lastFailure.endpoint, lastFailure.effectiveModel, lastFailure.sessionId, lastFailure.toolDebugMissing, lastFailure.toolDebugSummary, lastFailure.toolDebugPayload, debugLines, ); } throw lastError || new Error("All Antigravity endpoints failed"); } continue; } // If we get here without returning, something went wrong if (lastFailure) { return transformAntigravityResponse( lastFailure.response, lastFailure.streaming, lastFailure.debugContext, lastFailure.requestedModel, lastFailure.projectId, lastFailure.endpoint, lastFailure.effectiveModel, lastFailure.sessionId, lastFailure.toolDebugMissing, lastFailure.toolDebugSummary, lastFailure.toolDebugPayload, debugLines, ); } throw lastError || new Error("All Antigravity accounts failed"); } }, }; }, methods: [ { label: "OAuth with Google (Antigravity)", type: "oauth", authorize: async (inputs?: Record) => { const isHeadless = !!( process.env.SSH_CONNECTION || process.env.SSH_CLIENT || process.env.SSH_TTY || process.env.OPENCODE_HEADLESS ); // CLI flow (`opencode auth login`) passes an inputs object. if (inputs) { const accounts: Array> = []; const noBrowser = inputs.noBrowser === "true" || inputs["no-browser"] === "true"; const useManualMode = noBrowser || shouldSkipLocalServer(); // Check for existing accounts and prompt user for login mode let startFresh = true; let refreshAccountIndex: number | undefined; const existingStorage = await loadAccounts(); if (existingStorage && existingStorage.accounts.length > 0) { let menuResult; while (true) { const now = Date.now(); const existingAccounts = existingStorage.accounts.map((acc, idx) => { let status: 'active' | 'rate-limited' | 'expired' | 'verification-required' | 'unknown' = 'unknown'; if (acc.verificationRequired) { status = 'verification-required'; } else { const rateLimits = acc.rateLimitResetTimes; if (rateLimits) { const isRateLimited = Object.values(rateLimits).some( (resetTime) => typeof resetTime === 'number' && resetTime > now ); if (isRateLimited) { status = 'rate-limited'; } else { status = 'active'; } } else { status = 'active'; } if (acc.coolingDownUntil && acc.coolingDownUntil > now) { status = 'rate-limited'; } } return { email: acc.email, index: idx, addedAt: acc.addedAt, lastUsed: acc.lastUsed, status, isCurrentAccount: idx === (existingStorage.activeIndex ?? 0), enabled: acc.enabled !== false, }; }); menuResult = await promptLoginMode(existingAccounts); if (menuResult.mode === "check") { console.log("\n📊 Checking quotas for all accounts...\n"); const results = await checkAccountsQuota(existingStorage.accounts, client, providerId); let storageUpdated = false; for (const res of results) { const label = res.email || `Account ${res.index + 1}`; const disabledStr = res.disabled ? " (disabled)" : ""; console.log(`━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━`); console.log(` ${label}${disabledStr}`); console.log(`━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━`); if (res.status === "error") { console.log(` ❌ Error: ${res.error}\n`); continue; } // ANSI color codes const colors = { red: '\x1b[31m', orange: '\x1b[33m', // Yellow/orange green: '\x1b[32m', reset: '\x1b[0m', }; // Get color based on remaining percentage const getColor = (remaining?: number): string => { if (typeof remaining !== 'number') return colors.reset; if (remaining < 0.2) return colors.red; if (remaining < 0.6) return colors.orange; return colors.green; }; // Helper to create colored progress bar const createProgressBar = (remaining?: number, width: number = 20): string => { if (typeof remaining !== 'number') return '░'.repeat(width) + ' ???'; const filled = Math.round(remaining * width); const empty = width - filled; const color = getColor(remaining); const bar = `${color}${'█'.repeat(filled)}${colors.reset}${'░'.repeat(empty)}`; const pct = `${color}${Math.round(remaining * 100)}%${colors.reset}`.padStart(4 + color.length + colors.reset.length); return `${bar} ${pct}`; }; // Helper to format reset time with days support const formatReset = (resetTime?: string): string => { if (!resetTime) return ''; const ms = Date.parse(resetTime) - Date.now(); if (ms <= 0) return ' (resetting...)'; const hours = ms / (1000 * 60 * 60); if (hours >= 24) { const days = Math.floor(hours / 24); const remainingHours = Math.floor(hours % 24); if (remainingHours > 0) { return ` (resets in ${days}d ${remainingHours}h)`; } return ` (resets in ${days}d)`; } return ` (resets in ${formatWaitTime(ms)})`; }; // Display Gemini CLI Quota first (as requested - swap order) const hasGeminiCli = res.geminiCliQuota && res.geminiCliQuota.models.length > 0; console.log(`\n ┌─ Gemini CLI Quota`); if (!hasGeminiCli) { const errorMsg = res.geminiCliQuota?.error || "No Gemini CLI quota available"; console.log(` │ └─ ${errorMsg}`); } else { const models = res.geminiCliQuota!.models; models.forEach((model, idx) => { const isLast = idx === models.length - 1; const connector = isLast ? "└─" : "├─"; const bar = createProgressBar(model.remainingFraction); const reset = formatReset(model.resetTime); const modelName = model.modelId.padEnd(29); console.log(` │ ${connector} ${modelName} ${bar}${reset}`); }); } // Display Antigravity Quota second const hasAntigravity = res.quota && Object.keys(res.quota.groups).length > 0; console.log(` │`); console.log(` └─ Antigravity Quota`); if (!hasAntigravity) { const errorMsg = res.quota?.error || "No quota information available"; console.log(` └─ ${errorMsg}`); } else { const groups = res.quota!.groups; const groupEntries = [ { name: "Claude", data: groups.claude }, { name: "Gemini 3 Pro", data: groups["gemini-pro"] }, { name: "Gemini 3 Flash", data: groups["gemini-flash"] }, ].filter(g => g.data); groupEntries.forEach((g, idx) => { const isLast = idx === groupEntries.length - 1; const connector = isLast ? "└─" : "├─"; const bar = createProgressBar(g.data!.remainingFraction); const reset = formatReset(g.data!.resetTime); const modelName = g.name.padEnd(29); console.log(` ${connector} ${modelName} ${bar}${reset}`); }); } console.log(""); // Cache quota data for soft quota protection if (res.quota?.groups) { const acc = existingStorage.accounts[res.index]; if (acc) { acc.cachedQuota = res.quota.groups; acc.cachedQuotaUpdatedAt = Date.now(); storageUpdated = true; } } if (res.updatedAccount) { existingStorage.accounts[res.index] = { ...res.updatedAccount, cachedQuota: res.quota?.groups, cachedQuotaUpdatedAt: Date.now(), }; storageUpdated = true; } } if (storageUpdated) { await saveAccounts(existingStorage); } console.log(""); continue; } if (menuResult.mode === "manage") { if (menuResult.toggleAccountIndex !== undefined) { const acc = existingStorage.accounts[menuResult.toggleAccountIndex]; if (acc) { acc.enabled = acc.enabled === false; await saveAccounts(existingStorage); activeAccountManager?.setAccountEnabled(menuResult.toggleAccountIndex, acc.enabled); console.log(`\nAccount ${acc.email || menuResult.toggleAccountIndex + 1} ${acc.enabled ? 'enabled' : 'disabled'}.\n`); } } continue; } if (menuResult.mode === "verify" || menuResult.mode === "verify-all") { const verifyAll = menuResult.mode === "verify-all" || menuResult.verifyAll === true; if (verifyAll) { if (existingStorage.accounts.length === 0) { console.log("\nNo accounts available to verify.\n"); continue; } console.log(`\nChecking verification status for ${existingStorage.accounts.length} account(s)...\n`); let okCount = 0; let blockedCount = 0; let errorCount = 0; let storageUpdated = false; const blockedResults: Array<{ label: string; message: string; verifyUrl?: string }> = []; for (let i = 0; i < existingStorage.accounts.length; i++) { const account = existingStorage.accounts[i]; if (!account) continue; const label = account.email || `Account ${i + 1}`; process.stdout.write(`- [${i + 1}/${existingStorage.accounts.length}] ${label} ... `); const verification = await verifyAccountAccess(account, client, providerId); if (verification.status === "ok") { const { changed, wasVerificationRequired } = clearStoredAccountVerificationRequired(account, true); if (changed) { storageUpdated = true; } activeAccountManager?.clearAccountVerificationRequired(i, wasVerificationRequired); okCount += 1; console.log("ok"); continue; } if (verification.status === "blocked") { const changed = markStoredAccountVerificationRequired( account, verification.message, verification.verifyUrl, ); if (changed) { storageUpdated = true; } activeAccountManager?.markAccountVerificationRequired(i, verification.message, verification.verifyUrl); blockedCount += 1; console.log("needs verification"); const verifyUrl = verification.verifyUrl ?? account.verificationUrl; blockedResults.push({ label, message: verification.message, verifyUrl, }); continue; } errorCount += 1; console.log(`error (${verification.message})`); } if (storageUpdated) { await saveAccounts(existingStorage); } console.log(`\nVerification summary: ${okCount} ready, ${blockedCount} need verification, ${errorCount} errors.`); if (blockedResults.length > 0) { console.log("\nAccounts needing verification:"); for (const result of blockedResults) { console.log(`\n- ${result.label}`); console.log(` ${result.message}`); if (result.verifyUrl) { console.log(` URL: ${result.verifyUrl}`); } else { console.log(" URL: not provided by API response"); } } console.log(""); } else { console.log(""); } continue; } let verifyAccountIndex = menuResult.verifyAccountIndex; if (verifyAccountIndex === undefined) { verifyAccountIndex = await promptAccountIndexForVerification(existingAccounts); } if (verifyAccountIndex === undefined) { console.log("\nVerification cancelled.\n"); continue; } const account = existingStorage.accounts[verifyAccountIndex]; if (!account) { console.log(`\nAccount ${verifyAccountIndex + 1} not found.\n`); continue; } const label = account.email || `Account ${verifyAccountIndex + 1}`; console.log(`\nChecking verification status for ${label}...\n`); const verification = await verifyAccountAccess(account, client, providerId); if (verification.status === "ok") { const { changed, wasVerificationRequired } = clearStoredAccountVerificationRequired(account, true); if (changed) { await saveAccounts(existingStorage); } activeAccountManager?.clearAccountVerificationRequired(verifyAccountIndex, wasVerificationRequired); if (wasVerificationRequired) { console.log(`✓ ${label} is ready for requests and has been re-enabled.\n`); } else { console.log(`✓ ${label} is ready for requests.\n`); } continue; } if (verification.status === "blocked") { const changed = markStoredAccountVerificationRequired( account, verification.message, verification.verifyUrl, ); if (changed) { await saveAccounts(existingStorage); } activeAccountManager?.markAccountVerificationRequired( verifyAccountIndex, verification.message, verification.verifyUrl, ); const verifyUrl = verification.verifyUrl ?? account.verificationUrl; console.log(`⚠ ${label} needs Google verification before it can be used.`); if (verification.message) { console.log(verification.message); } console.log(`${label} has been disabled until verification is completed.`); if (verifyUrl) { console.log(`\nVerification URL:\n${verifyUrl}\n`); if (await promptOpenVerificationUrl()) { const opened = await openBrowser(verifyUrl); if (opened) { console.log("Opened verification URL in your browser.\n"); } else { console.log("Could not open browser automatically. Please open the URL manually.\n"); } } } else { console.log("No verification URL was returned. Try re-authenticating this account.\n"); } continue; } console.log(`✗ ${label}: ${verification.message}\n`); continue; } break; } if (menuResult.mode === "cancel") { return { url: "", instructions: "Authentication cancelled", method: "auto", callback: async () => ({ type: "failed", error: "Authentication cancelled" }), }; } if (menuResult.deleteAccountIndex !== undefined) { const updatedAccounts = existingStorage.accounts.filter( (_, idx) => idx !== menuResult.deleteAccountIndex ); // Use saveAccountsReplace to bypass merge (otherwise deleted account gets merged back) await saveAccountsReplace({ version: 4, accounts: updatedAccounts, activeIndex: 0, activeIndexByFamily: { claude: 0, gemini: 0 }, }); // Sync in-memory state so deleted account stops being used immediately activeAccountManager?.removeAccountByIndex(menuResult.deleteAccountIndex); console.log("\nAccount deleted.\n"); if (updatedAccounts.length > 0) { const fallbackAccount = updatedAccounts[0]; if (fallbackAccount?.refreshToken) { const fallbackResult = buildAuthSuccessFromStoredAccount(fallbackAccount); try { await client.auth.set({ path: { id: providerId }, body: { type: "oauth", refresh: fallbackResult.refresh, access: "", expires: 0 }, }); } catch (storeError) { log.error("Failed to update stored Antigravity OAuth credentials", { error: String(storeError) }); } const label = fallbackAccount.email || `Account ${1}`; return { url: "", instructions: `Account deleted. Using ${label} for future requests.`, method: "auto", callback: async () => fallbackResult, }; } } try { await client.auth.set({ path: { id: providerId }, body: { type: "oauth", refresh: "", access: "", expires: 0 }, }); } catch (storeError) { log.error("Failed to clear stored Antigravity OAuth credentials", { error: String(storeError) }); } return { url: "", instructions: "All accounts deleted. Run `opencode auth login` to reauthenticate.", method: "auto", callback: async () => ({ type: "failed", error: "All accounts deleted. Reauthentication required.", }), }; } if (menuResult.refreshAccountIndex !== undefined) { refreshAccountIndex = menuResult.refreshAccountIndex; const refreshEmail = existingStorage.accounts[refreshAccountIndex]?.email; console.log(`\nRe-authenticating ${refreshEmail || 'account'}...\n`); startFresh = false; } if (menuResult.deleteAll) { await clearAccounts(); console.log("\nAll accounts deleted.\n"); startFresh = true; try { await client.auth.set({ path: { id: providerId }, body: { type: "oauth", refresh: "", access: "", expires: 0 }, }); } catch (storeError) { log.error("Failed to clear stored Antigravity OAuth credentials", { error: String(storeError) }); } } else { startFresh = menuResult.mode === "fresh"; } if (startFresh && !menuResult.deleteAll) { console.log("\nStarting fresh - existing accounts will be replaced.\n"); } else if (!startFresh) { console.log("\nAdding to existing accounts.\n"); } } while (accounts.length < MAX_OAUTH_ACCOUNTS) { console.log(`\n=== Antigravity OAuth (Account ${accounts.length + 1}) ===`); const projectId = await promptProjectId(); const result = await (async (): Promise => { const authorization = await authorizeAntigravity(projectId); const fallbackState = getStateFromAuthorizationUrl(authorization.url); console.log("\nOAuth URL:\n" + authorization.url + "\n"); if (useManualMode) { const browserOpened = await openBrowser(authorization.url); if (!browserOpened) { console.log("Could not open browser automatically."); console.log("Please open the URL above manually in your local browser.\n"); } return promptManualOAuthInput(fallbackState); } let listener: OAuthListener | null = null; if (!isHeadless) { try { listener = await startOAuthListener(); } catch { listener = null; } } if (!isHeadless) { await openBrowser(authorization.url); } if (listener) { try { const SOFT_TIMEOUT_MS = 30000; const callbackPromise = listener.waitForCallback(); const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error("SOFT_TIMEOUT")), SOFT_TIMEOUT_MS) ); let callbackUrl: URL; try { callbackUrl = await Promise.race([callbackPromise, timeoutPromise]); } catch (err) { if (err instanceof Error && err.message === "SOFT_TIMEOUT") { console.log("\n⏳ Automatic callback not received after 30 seconds."); console.log("You can paste the redirect URL manually.\n"); console.log("OAuth URL (in case you need it again):"); console.log(authorization.url + "\n"); try { await listener.close(); } catch {} return promptManualOAuthInput(fallbackState); } throw err; } const params = extractOAuthCallbackParams(callbackUrl); if (!params) { return { type: "failed", error: "Missing code or state in callback URL" }; } return exchangeAntigravity(params.code, params.state); } catch (error) { if (error instanceof Error && error.message !== "SOFT_TIMEOUT") { return { type: "failed", error: error.message, }; } return { type: "failed", error: error instanceof Error ? error.message : "Unknown error", }; } finally { try { await listener.close(); } catch {} } } return promptManualOAuthInput(fallbackState); })(); if (result.type === "failed") { if (accounts.length === 0) { return { url: "", instructions: `Authentication failed: ${result.error}`, method: "auto", callback: async () => result, }; } console.warn( `[opencode-antigravity-auth] Skipping failed account ${accounts.length + 1}: ${result.error}`, ); break; } accounts.push(result); try { await client.tui.showToast({ body: { message: `Account ${accounts.length} authenticated${result.email ? ` (${result.email})` : ""}`, variant: "success", }, }); } catch { } try { if (refreshAccountIndex !== undefined) { const currentStorage = await loadAccounts(); if (currentStorage) { const updatedAccounts = [...currentStorage.accounts]; const parts = parseRefreshParts(result.refresh); if (parts.refreshToken) { updatedAccounts[refreshAccountIndex] = { email: result.email ?? updatedAccounts[refreshAccountIndex]?.email, refreshToken: parts.refreshToken, projectId: parts.projectId ?? updatedAccounts[refreshAccountIndex]?.projectId, managedProjectId: parts.managedProjectId ?? updatedAccounts[refreshAccountIndex]?.managedProjectId, addedAt: updatedAccounts[refreshAccountIndex]?.addedAt ?? Date.now(), lastUsed: Date.now(), }; await saveAccounts({ version: 4, accounts: updatedAccounts, activeIndex: currentStorage.activeIndex, activeIndexByFamily: currentStorage.activeIndexByFamily, }); } } } else { const isFirstAccount = accounts.length === 1; await persistAccountPool([result], isFirstAccount && startFresh); } } catch { } if (refreshAccountIndex !== undefined) { break; } if (accounts.length >= MAX_OAUTH_ACCOUNTS) { break; } // Get the actual deduplicated account count from storage for the prompt let currentAccountCount = accounts.length; try { const currentStorage = await loadAccounts(); if (currentStorage) { currentAccountCount = currentStorage.accounts.length; } } catch { // Fall back to accounts.length if we can't read storage } const addAnother = await promptAddAnotherAccount(currentAccountCount); if (!addAnother) { break; } } const primary = accounts[0]; if (!primary) { return { url: "", instructions: "Authentication cancelled", method: "auto", callback: async () => ({ type: "failed", error: "Authentication cancelled" }), }; } let actualAccountCount = accounts.length; try { const finalStorage = await loadAccounts(); if (finalStorage) { actualAccountCount = finalStorage.accounts.length; } } catch { } const successMessage = refreshAccountIndex !== undefined ? `Token refreshed successfully.` : `Multi-account setup complete (${actualAccountCount} account(s)).`; return { url: "", instructions: successMessage, method: "auto", callback: async (): Promise => primary, }; } // TUI flow (`/connect`) does not support per-account prompts. // Default to adding new accounts (non-destructive). // Users can run `opencode auth logout` first if they want a fresh start. const projectId = ""; // Check existing accounts count for toast message const existingStorage = await loadAccounts(); const existingCount = existingStorage?.accounts.length ?? 0; const useManualFlow = isHeadless || shouldSkipLocalServer(); let listener: OAuthListener | null = null; if (!useManualFlow) { try { listener = await startOAuthListener(); } catch { listener = null; } } const authorization = await authorizeAntigravity(projectId); const fallbackState = getStateFromAuthorizationUrl(authorization.url); if (!useManualFlow) { const browserOpened = await openBrowser(authorization.url); if (!browserOpened) { listener?.close().catch(() => {}); listener = null; } } if (listener) { return { url: authorization.url, instructions: "Complete sign-in in your browser. We'll automatically detect the redirect back to localhost.", method: "auto", callback: async (): Promise => { const CALLBACK_TIMEOUT_MS = 30000; try { const callbackPromise = listener.waitForCallback(); const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error("CALLBACK_TIMEOUT")), CALLBACK_TIMEOUT_MS), ); let callbackUrl: URL; try { callbackUrl = await Promise.race([callbackPromise, timeoutPromise]); } catch (err) { if (err instanceof Error && err.message === "CALLBACK_TIMEOUT") { return { type: "failed", error: "Callback timeout - please use CLI with --no-browser flag for manual input", }; } throw err; } const params = extractOAuthCallbackParams(callbackUrl); if (!params) { return { type: "failed", error: "Missing code or state in callback URL" }; } const result = await exchangeAntigravity(params.code, params.state); if (result.type === "success") { try { await persistAccountPool([result], false); } catch { } const newTotal = existingCount + 1; const toastMessage = existingCount > 0 ? `Added account${result.email ? ` (${result.email})` : ""} - ${newTotal} total` : `Authenticated${result.email ? ` (${result.email})` : ""}`; try { await client.tui.showToast({ body: { message: toastMessage, variant: "success", }, }); } catch { } } return result; } catch (error) { return { type: "failed", error: error instanceof Error ? error.message : "Unknown error", }; } finally { try { await listener.close(); } catch { } } }, }; } return { url: authorization.url, instructions: "Visit the URL above, complete OAuth, then paste either the full redirect URL or the authorization code.", method: "code", callback: async (codeInput: string): Promise => { const params = parseOAuthCallbackInput(codeInput, fallbackState); if ("error" in params) { return { type: "failed", error: params.error }; } const result = await exchangeAntigravity(params.code, params.state); if (result.type === "success") { try { // TUI flow adds to existing accounts (non-destructive) await persistAccountPool([result], false); } catch { // ignore } // Show appropriate toast message const newTotal = existingCount + 1; const toastMessage = existingCount > 0 ? `Added account${result.email ? ` (${result.email})` : ""} - ${newTotal} total` : `Authenticated${result.email ? ` (${result.email})` : ""}`; try { await client.tui.showToast({ body: { message: toastMessage, variant: "success", }, }); } catch { // TUI may not be available } } return result; }, }; }, }, { label: "Manually enter API Key", type: "api", }, ], }, }; }; export const AntigravityCLIOAuthPlugin = createAntigravityPlugin(ANTIGRAVITY_PROVIDER_ID); export const GoogleOAuthPlugin = AntigravityCLIOAuthPlugin; function toUrlString(value: RequestInfo): string { if (typeof value === "string") { return value; } const candidate = (value as Request).url; if (candidate) { return candidate; } return value.toString(); } function toWarmupStreamUrl(value: RequestInfo): string { const urlString = toUrlString(value); try { const url = new URL(urlString); if (!url.pathname.includes(":streamGenerateContent")) { url.pathname = url.pathname.replace(":generateContent", ":streamGenerateContent"); } url.searchParams.set("alt", "sse"); return url.toString(); } catch { return urlString; } } function extractModelFromUrl(urlString: string): string | null { const match = urlString.match(/\/models\/([^:\/?]+)(?::\w+)?/); return match?.[1] ?? null; } function extractModelFromUrlWithSuffix(urlString: string): string | null { const match = urlString.match(/\/models\/([^:\/\?]+)/); return match?.[1] ?? null; } function getModelFamilyFromUrl(urlString: string): ModelFamily { const model = extractModelFromUrl(urlString); let family: ModelFamily = "gemini"; if (model && model.includes("claude")) { family = "claude"; } if (isDebugEnabled()) { logModelFamily(urlString, model, family); } return family; } function resolveQuotaFallbackHeaderStyle(input: { family: ModelFamily; headerStyle: HeaderStyle; alternateStyle: HeaderStyle | null; }): HeaderStyle | null { if (input.family !== "gemini") { return null; } if (!input.alternateStyle || input.alternateStyle === input.headerStyle) { return null; } return input.alternateStyle; } type HeaderRoutingDecision = { cliFirst: boolean; preferredHeaderStyle: HeaderStyle; explicitQuota: boolean; allowQuotaFallback: boolean; }; function resolveHeaderRoutingDecision( urlString: string, family: ModelFamily, config: AntigravityConfig, ): HeaderRoutingDecision { const cliFirst = getCliFirst(config); const preferredHeaderStyle = getHeaderStyleFromUrl(urlString, family, cliFirst); const explicitQuota = isExplicitQuotaFromUrl(urlString); return { cliFirst, preferredHeaderStyle, explicitQuota, allowQuotaFallback: family === "gemini", }; } function getCliFirst(config: AntigravityConfig): boolean { return (config as AntigravityConfig & { cli_first?: boolean }).cli_first ?? false; } function getHeaderStyleFromUrl( urlString: string, family: ModelFamily, cliFirst: boolean = false, ): HeaderStyle { if (family === "claude") { return "antigravity"; } const modelWithSuffix = extractModelFromUrlWithSuffix(urlString); if (!modelWithSuffix) { return cliFirst ? "gemini-cli" : "antigravity"; } const { quotaPreference } = resolveModelWithTier(modelWithSuffix, { cli_first: cliFirst }); return quotaPreference ?? "antigravity"; } function isExplicitQuotaFromUrl(urlString: string): boolean { const modelWithSuffix = extractModelFromUrlWithSuffix(urlString); if (!modelWithSuffix) { return false; } const { explicitQuota } = resolveModelWithTier(modelWithSuffix); return explicitQuota ?? false; } export const __testExports = { getHeaderStyleFromUrl, resolveHeaderRoutingDecision, resolveQuotaFallbackHeaderStyle, }; ================================================ FILE: src/shims.d.ts ================================================ declare module "@openauthjs/openauth/pkce" { interface PkcePair { challenge: string; verifier: string; } export function generatePKCE(): Promise; } ================================================ FILE: tsconfig.build.json ================================================ { "extends": "./tsconfig.json", "compilerOptions": { "noEmit": false, "outDir": "dist", "declaration": true, "declarationMap": true, "sourceMap": true, "allowImportingTsExtensions": false }, "include": ["src/**/*.ts", "src/**/*.tsx", "index.ts"], "exclude": ["src/**/*.test.ts", "src/**/*.test.tsx", "src/**/*.spec.ts", "src/**/*.spec.tsx"] } ================================================ FILE: tsconfig.json ================================================ { "include": ["src/**/*", "scripts/**/*"], "exclude": ["node_modules", "dist", "temp_research", "script"], "compilerOptions": { // Environment setup & latest features "lib": ["ESNext", "DOM"], "target": "ESNext", "module": "Preserve", "moduleDetection": "force", "jsx": "react-jsx", "allowJs": true, // Bundler mode "moduleResolution": "bundler", "typeRoots": ["./node_modules/@types"], "allowImportingTsExtensions": true, "verbatimModuleSyntax": true, "noEmit": true, // Best practices "strict": true, "skipLibCheck": true, "noFallthroughCasesInSwitch": true, "noUncheckedIndexedAccess": true, "noImplicitOverride": true, // Some stricter flags (disabled by default) "noUnusedLocals": false, "noUnusedParameters": false, "noPropertyAccessFromIndexSignature": false } } ================================================ FILE: vitest.config.ts ================================================ import { defineConfig } from 'vitest/config'; export default defineConfig({ test: { globals: true, environment: 'node', include: ['src/**/*.test.ts'], exclude: ['node_modules', 'dist'], }, });