[
  {
    "path": ".github/FUNDING.yml",
    "content": "# These are supported funding model platforms\n\ngithub: NoeFabris\npatreon: # Replace with a single Patreon username\nopen_collective: # Replace with a single Open Collective username\nko_fi: noefabris\ntidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel\ncommunity_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry\nliberapay: # Replace with a single Liberapay username\nissuehunt: # Replace with a single IssueHunt username\nlfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry\npolar: # Replace with a single Polar username\nbuy_me_a_coffee: # Replace with a single Buy Me a Coffee username\nthanks_dev: # Replace with a single thanks.dev username\ncustom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/bug_report.yml",
    "content": "name: Bug Report\ndescription: Report a bug or issue with the plugin\nlabels: [\"bug\", \"needs-triage\"]\nbody:\n  - type: markdown\n    attributes:\n      value: |\n        ## Before opening an issue\n\n        > ⚠️ **PLEASE READ THE TROUBLESHOOTING GUIDE FIRST**\n        > \n        > Most issues (rate limits, hanging prompts, auth errors) are covered in our troubleshooting guide.\n        > **Your issue may already have a solution!**\n\n        **Please write a descriptive title above** (e.g., \"Auth fails with invalid_grant after token refresh\")\n\n        > 🌐 **ENGLISH ONLY**\n        > \n        > Issues must be written in English. Issues in other languages will be closed immediately.\n\n        Check the following resources first:\n        - **[📋 Troubleshooting Guide](https://github.com/NoeFabris/opencode-antigravity-auth/blob/main/docs/TROUBLESHOOTING.md)** - common issues and solutions\n        - [Existing issues](https://github.com/NoeFabris/opencode-antigravity-auth/issues?q=is%3Aissue) - your issue may already be reported\n\n        **Issues without debug logs will be closed.**\n\n  - type: checkboxes\n    id: prerequisites\n    attributes:\n      label: Pre-submission checklist\n      options:\n        - label: I have searched existing issues for duplicates\n          required: true\n        - label: I have read the [📋 Troubleshooting Guide](https://github.com/NoeFabris/opencode-antigravity-auth/blob/main/docs/TROUBLESHOOTING.md)\n          required: true\n        - label: I have read the [README](https://github.com/NoeFabris/opencode-antigravity-auth#readme) installation instructions\n\n  - type: dropdown\n    id: model\n    attributes:\n      label: Model used\n      description: Which model were you using when the issue occurred?\n      options:\n        - antigravity-gemini-3-pro\n        - antigravity-gemini-3-flash\n        - antigravity-claude-sonnet-4-6\n        - antigravity-claude-opus-4-6-thinking\n        - gemini-2.5-flash\n        - gemini-2.5-pro\n        - gemini-3-flash-preview\n        - gemini-3-pro-preview\n        - Other (specify in description)\n        - Not applicable\n    validations:\n      required: true\n\n  - type: textarea\n    id: error-message\n    attributes:\n      label: Exact error message\n      description: Copy-paste the exact error message. Do not paraphrase.\n      placeholder: |\n        Paste the exact error message here.\n        Include any error codes or stack traces if available.\n      render: text\n    validations:\n      required: true\n\n  - type: textarea\n    id: description\n    attributes:\n      label: Bug description\n      description: A clear and concise description of what the bug is.\n      placeholder: Describe the bug...\n    validations:\n      required: true\n\n  - type: textarea\n    id: steps\n    attributes:\n      label: Steps to reproduce\n      description: How can we reproduce this issue?\n      placeholder: |\n        1. Run command...\n        2. Click on...\n        3. See error...\n    validations:\n      required: true\n\n  - type: dropdown\n    id: worked-before\n    attributes:\n      label: Did this ever work?\n      description: Has this feature worked for you before?\n      options:\n        - First time setup (never worked)\n        - Worked before, now broken (regression)\n        - Not sure\n    validations:\n      required: true\n\n  - type: dropdown\n    id: num-accounts\n    attributes:\n      label: Number of Google accounts configured\n      options:\n        - \"1\"\n        - \"2-3\"\n        - \"4+\"\n    validations:\n      required: true\n\n  - type: dropdown\n    id: reproducibility\n    attributes:\n      label: Reproducibility\n      description: How often does this issue occur?\n      options:\n        - Always (100%)\n        - Often (>50%)\n        - Sometimes (<50%)\n        - Once (cannot reproduce)\n    validations:\n      required: true\n\n  - type: input\n    id: plugin-version\n    attributes:\n      label: Plugin version\n      description: \"Run: `npm list opencode-antigravity-auth -g 2>/dev/null || npm list opencode-antigravity-auth`\"\n      placeholder: \"e.g., 1.2.9-beta.5\"\n    validations:\n      required: true\n\n  - type: input\n    id: opencode-version\n    attributes:\n      label: OpenCode version\n      description: \"Run: `opencode --version`\"\n      placeholder: \"e.g., 0.3.0\"\n    validations:\n      required: true\n\n  - type: input\n    id: os\n    attributes:\n      label: Operating System\n      placeholder: \"e.g., macOS 15.2, Ubuntu 24.04, Windows 11\"\n    validations:\n      required: true\n\n  - type: input\n    id: node-version\n    attributes:\n      label: Node.js version\n      description: \"Run: `node --version`\"\n      placeholder: \"e.g., v22.12.0\"\n    validations:\n      required: true\n\n  - type: dropdown\n    id: environment\n    attributes:\n      label: Environment type\n      description: Are you running in a special environment?\n      options:\n        - Standard (native terminal)\n        - WSL2\n        - Docker\n        - SSH / Remote\n        - VS Code Remote\n        - Other (specify in additional context)\n    validations:\n      required: true\n\n  - type: textarea\n    id: mcp-servers\n    attributes:\n      label: MCP servers installed\n      description: List any MCP (Model Context Protocol) servers you have configured. This helps us identify potential interactions or conflicts.\n      placeholder: |\n        Example:\n        - chrome-devtools\n        - figma\n    validations:\n      required: false\n\n  - type: textarea\n    id: logs\n    attributes:\n      label: Debug logs (REQUIRED)\n      description: |\n        **Issues without logs will be closed.**\n\n        1. Enable debug logging: `export OPENCODE_ANTIGRAVITY_DEBUG=2`\n        2. Reproduce the issue\n        3. Find log file at:\n           - macOS/Linux: `~/.config/opencode/antigravity-logs/antigravity-debug-<timestamp>.log`\n           - Windows: `%APPDATA%\\opencode\\antigravity-logs\\antigravity-debug-<timestamp>.log`\n        4. Paste relevant sections or attach the file\n      placeholder: Paste your debug logs here...\n      render: text\n    validations:\n      required: true\n\n  - type: textarea\n    id: config\n    attributes:\n      label: Configuration (optional)\n      description: Share your antigravity.json if relevant (remove sensitive data)\n      placeholder: |\n        {\n          \"quiet_mode\": false,\n          \"debug\": false\n        }\n      render: json\n    validations:\n      required: false\n\n  - type: checkboxes\n    id: compliance\n    attributes:\n      label: Compliance\n      options:\n        - label: I'm using this plugin for personal development only\n          required: true\n        - label: This issue is not related to commercial use or TOS violations\n          required: true\n\n  - type: textarea\n    id: additional\n    attributes:\n      label: Additional context\n      description: Any other relevant information\n    validations:\n      required: false\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/config.yml",
    "content": "blank_issues_enabled: false\ncontact_links:\n  - name: Troubleshooting Guide\n    url: https://github.com/NoeFabris/opencode-antigravity-auth#troubleshoot\n    about: Check the troubleshooting section first - most issues are covered here\n  - name: Search Existing Issues\n    url: https://github.com/NoeFabris/opencode-antigravity-auth/issues?q=is%3Aissue\n    about: Your issue may have already been reported or resolved\n  - name: Discussions\n    url: https://github.com/NoeFabris/opencode-antigravity-auth/discussions\n    about: Ask questions or discuss the plugin with the community\n  - name: Google Cloud Support\n    url: https://cloud.google.com/support\n    about: For Google Cloud account or billing issues, contact Google Cloud directly\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/feature_request.yml",
    "content": "name: Feature Request\ndescription: Suggest a new feature or enhancement\nlabels: [\"enhancement\", \"needs-triage\"]\nbody:\n  - type: markdown\n    attributes:\n      value: |\n        ## Before opening a feature request\n\n        **Please write a descriptive title above** (e.g., \"Add support for custom retry strategies\")\n\n        > 🌐 **ENGLISH ONLY**\n        > \n        > Issues must be written in English. Issues in other languages will be closed immediately.\n\n        Check if this feature has already been requested:\n        - [Existing feature requests](https://github.com/NoeFabris/opencode-antigravity-auth/issues?q=is%3Aissue+label%3Aenhancement)\n        - [Discussions](https://github.com/NoeFabris/opencode-antigravity-auth/discussions)\n\n  - type: checkboxes\n    id: prerequisites\n    attributes:\n      label: Pre-submission checklist\n      options:\n        - label: I have searched existing issues and feature requests for duplicates\n          required: true\n        - label: I have read the [README](https://github.com/NoeFabris/opencode-antigravity-auth#readme)\n          required: true\n\n  - type: textarea\n    id: description\n    attributes:\n      label: Feature description\n      description: A clear description of the feature you'd like to see.\n      placeholder: Describe the feature...\n    validations:\n      required: true\n\n  - type: textarea\n    id: use-case\n    attributes:\n      label: Use case\n      description: Explain how this feature would be used and what problem it solves.\n      placeholder: |\n        As a user, I want to...\n        So that I can...\n    validations:\n      required: true\n\n  - type: textarea\n    id: implementation\n    attributes:\n      label: Proposed implementation\n      description: If you have ideas about how this could be implemented, share them here.\n      placeholder: Optional implementation details...\n    validations:\n      required: false\n\n  - type: textarea\n    id: alternatives\n    attributes:\n      label: Alternatives considered\n      description: Have you considered any alternative solutions or workarounds?\n      placeholder: Other approaches you've thought about...\n    validations:\n      required: false\n\n  - type: checkboxes\n    id: compliance\n    attributes:\n      label: Compliance\n      options:\n        - label: This feature is for personal development use\n          required: true\n        - label: This feature does not violate or circumvent Google's Terms of Service\n          required: true\n\n  - type: textarea\n    id: additional\n    attributes:\n      label: Additional context\n      description: Add any other context, screenshots, or examples.\n    validations:\n      required: false\n"
  },
  {
    "path": ".github/workflows/ci.yml",
    "content": "name: CI\n\non:\n  push:\n    branches: [main]\n  pull_request:\n    branches: [main]\n\njobs:\n  test:\n    name: Test on Node.js\n    runs-on: ubuntu-latest\n\n    steps:\n      - name: Checkout code\n        uses: actions/checkout@v4\n\n      - name: Setup Node.js\n        uses: actions/setup-node@v4\n        with:\n          node-version: 20\n          cache: 'npm'\n\n      - name: Install dependencies\n        run: npm ci\n\n      - name: Run type check\n        run: npm run typecheck\n\n      - name: Run tests\n        run: npm test\n\n      - name: Build\n        run: npm run build\n"
  },
  {
    "path": ".github/workflows/issue-triage.yml",
    "content": "name: '🏷️ Issue Triage'\n\non:\n  issues:\n    types:\n      - 'opened'\n      - 'reopened'\n  issue_comment:\n    types:\n      - 'created'\n  workflow_dispatch:\n    inputs:\n      issue_number:\n        description: 'Issue number to triage'\n        required: true\n        type: 'number'\n\nconcurrency:\n  group: '${{ github.workflow }}-${{ github.event.issue.number || github.event.inputs.issue_number }}'\n  cancel-in-progress: true\n\npermissions:\n  contents: 'read'\n  issues: 'write'\n\njobs:\n  triage-issue:\n    if: |\n      github.event_name == 'workflow_dispatch' ||\n      github.event_name == 'issues' ||\n      (\n        github.event_name == 'issue_comment' &&\n        contains(github.event.comment.body, '/triage') &&\n        (\n          github.event.comment.author_association == 'OWNER' ||\n          github.event.comment.author_association == 'MEMBER' ||\n          github.event.comment.author_association == 'COLLABORATOR'\n        )\n      )\n    timeout-minutes: 30\n    runs-on: self-hosted\n\n    steps:\n      - name: 'Check if already triaged'\n        id: 'check_labels'\n        if: github.event_name != 'workflow_dispatch'\n        uses: 'actions/github-script@v7'\n        with:\n          script: |\n            const labels = context.payload.issue?.labels?.map(l => l.name) || [];\n            const triageLabels = ['bug', 'enhancement', 'documentation', 'question', 'invalid'];\n            const areaLabels = labels.filter(l => l.startsWith('area/'));\n            const hasTriageLabel = labels.some(l => triageLabels.includes(l)) || areaLabels.length > 0;\n            const isRetriage = context.payload.comment?.body?.includes('/triage');\n            \n            if (hasTriageLabel && !labels.includes('needs-triage') && !isRetriage) {\n              core.info(`Issue already triaged: ${labels.join(', ')}. Skipping.`);\n              core.setOutput('skip', 'true');\n            } else {\n              core.setOutput('skip', 'false');\n            }\n\n      - name: 'Get issue data'\n        id: 'get_issue'\n        if: steps.check_labels.outputs.skip != 'true'\n        uses: 'actions/github-script@v7'\n        with:\n          script: |\n            const issueNumber = context.payload.inputs?.issue_number || context.issue?.number;\n            core.setOutput('number', issueNumber);\n\n      - name: 'Run Opencode Triage'\n        id: 'opencode_triage'\n        if: steps.check_labels.outputs.skip != 'true'\n        run: |\n          echo \"Running Opencode Triage on issue ${{ steps.get_issue.outputs.number }}...\"\n\n          # Move to the repository\n          cd /home/admin/coding/opencode-antigravity-auth\n          \n          # Run CLI with the agent config\n          OUTPUT=$(opencode run --agent triage-bot \"Triage issue ${{ steps.get_issue.outputs.number }}\")\n          \n          echo \"Opencode Output: $OUTPUT\"\n          \n          echo \"result<<EOF\" >> $GITHUB_OUTPUT\n          echo \"$OUTPUT\" >> $GITHUB_OUTPUT\n          echo \"EOF\" >> $GITHUB_OUTPUT\n\n      - name: 'Apply Labels and Respond'\n        if: steps.check_labels.outputs.skip != 'true'\n        uses: 'actions/github-script@v7'\n        env:\n          ISSUE_NUMBER: '${{ steps.get_issue.outputs.number }}'\n          OPENCODE_OUTPUT: '${{ steps.opencode_triage.outputs.result }}'\n        with:\n          script: |\n            const rawOutput = process.env.OPENCODE_OUTPUT;\n            if (!rawOutput) {\n              core.warning('No triage output available');\n              return;\n            }\n            \n            core.info(`Triage output: ${rawOutput}`);\n            \n            let parsed;\n            try {\n              parsed = JSON.parse(rawOutput.trim());\n            } catch (e) {\n              // Try to extract JSON from code blocks first\n              const codeBlockMatch = rawOutput.match(/```(?:json)?\\s*([\\s\\S]*?)\\s*```/);\n              \n              // Find the LAST JSON object containing triage fields (avoid matching code snippets)\n              // Match JSON objects that contain \"type_label\" to ensure we get the triage output\n              const triageJsonMatch = rawOutput.match(/\\{\"type_label\"[\\s\\S]*?\\}(?=\\s*$|\\s*```|\\s*\\n\\n)/);\n              \n              // Fallback: find all potential JSON objects and try parsing from the end\n              let jsonStr = codeBlockMatch?.[1] || triageJsonMatch?.[0];\n              \n              if (!jsonStr) {\n                // Last resort: find all { } pairs and try parsing from the last one\n                const allMatches = [...rawOutput.matchAll(/\\{[^{}]*(?:\\{[^{}]*\\}[^{}]*)*\\}/g)];\n                for (let i = allMatches.length - 1; i >= 0; i--) {\n                  try {\n                    const candidate = JSON.parse(allMatches[i][0]);\n                    if (candidate.type_label && candidate.area_label) {\n                      jsonStr = allMatches[i][0];\n                      break;\n                    }\n                  } catch {}\n                }\n              }\n              \n              if (jsonStr) {\n                try {\n                  parsed = JSON.parse(jsonStr.trim());\n                } catch (e2) {\n                  core.setFailed(`Failed to parse output: ${rawOutput}`);\n                  return;\n                }\n              } else {\n                core.setFailed(`Failed to parse output: ${rawOutput}`);\n                return;\n              }\n            }\n            \n            const typeLabel = parsed.type_label;\n            const areaLabel = parsed.area_label;\n            const duplicateOf = parsed.duplicate_of;\n            const suggestedResponse = parsed.suggested_response;\n            \n            // Validate required fields\n            if (!typeLabel || !areaLabel || suggestedResponse === undefined) {\n              core.setFailed(`Invalid JSON structure: missing required fields. Parsed: ${JSON.stringify(parsed)}`);\n              return;\n            }\n            \n            const validTypeLabels = ['bug', 'enhancement', 'documentation', 'question', 'invalid'];\n            const validAreaLabels = ['area/auth', 'area/models', 'area/config', 'area/compat'];\n            \n            if (!validTypeLabels.includes(typeLabel)) {\n              core.warning(`Invalid type_label: ${typeLabel}`);\n              return;\n            }\n            if (!validAreaLabels.includes(areaLabel)) {\n              core.warning(`Invalid area_label: ${areaLabel}`);\n              return;\n            }\n            \n            let labelsToAdd = [];\n            let labelsToRemove = ['needs-triage'];\n            \n            // Remove existing triage labels to prevent conflicts\n            validTypeLabels.forEach(label => labelsToRemove.push(label));\n            validAreaLabels.forEach(label => labelsToRemove.push(label));\n            \n            // Only add one type label and one area label\n            if (typeLabel && validTypeLabels.includes(typeLabel)) {\n              labelsToAdd.push(typeLabel);\n            }\n            if (areaLabel && validAreaLabels.includes(areaLabel)) {\n              labelsToAdd.push(areaLabel);\n            }\n            if (duplicateOf) {\n              labelsToAdd.push('duplicate');\n            }\n            \n            // Validate single label selection and deduplicate\n            const typeLabels = labelsToAdd.filter(label => validTypeLabels.includes(label));\n            const areaLabels = labelsToAdd.filter(label => validAreaLabels.includes(label));\n            const otherLabels = labelsToAdd.filter(label => !validTypeLabels.includes(label) && !validAreaLabels.includes(label));\n            \n            if (typeLabels.length > 1) {\n              core.warning(`Multiple type labels detected: ${typeLabels.join(', ')}. Using only: ${typeLabel}`);\n            }\n            if (areaLabels.length > 1) {\n              core.warning(`Multiple area labels detected: ${areaLabels.join(', ')}. Using only: ${areaLabel}`);\n            }\n            \n            // Rebuild labelsToAdd with single type and area labels\n            labelsToAdd = [];\n            if (typeLabel && validTypeLabels.includes(typeLabel)) {\n              labelsToAdd.push(typeLabel);\n            }\n            if (areaLabel && validAreaLabels.includes(areaLabel)) {\n              labelsToAdd.push(areaLabel);\n            }\n            labelsToAdd.push(...otherLabels);\n            \n            // Avoid removing labels that are being added\n            labelsToRemove = labelsToRemove.filter(label => !labelsToAdd.includes(label));\n            \n            const issueNumber = parseInt(process.env.ISSUE_NUMBER);\n            \n            await github.rest.issues.addLabels({\n              owner: context.repo.owner,\n              repo: context.repo.repo,\n              issue_number: issueNumber,\n              labels: labelsToAdd\n            });\n            core.info(`Added labels: ${labelsToAdd.join(', ')}`);\n            \n            for (const label of labelsToRemove) {\n              try {\n                await github.rest.issues.removeLabel({\n                  owner: context.repo.owner,\n                  repo: context.repo.repo,\n                  issue_number: issueNumber,\n                  name: label\n                });\n                core.info(`Removed label: ${label}`);\n              } catch (e) {\n                core.info(`Label ${label} not present, skipping removal`);\n              }\n            }\n            \n            if (suggestedResponse && suggestedResponse.trim()) {\n              let body = `👋 Thanks for opening this issue!\\n\\n${suggestedResponse}`;\n              \n              if (duplicateOf) {\n                body += `\\n\\n🔗 This appears to be related to #${duplicateOf}. Please check that issue for updates.`;\n              }\n              \n              body += `\\n\\n---\\n*This is an automated response. A maintainer will review your issue soon.*`;\n              \n              await github.rest.issues.createComment({\n                owner: context.repo.owner,\n                repo: context.repo.repo,\n                issue_number: issueNumber,\n                body: body\n              });\n              core.info('Posted response comment');\n            }\n            \n            if (duplicateOf) {\n              await github.rest.issues.update({\n                owner: context.repo.owner,\n                repo: context.repo.repo,\n                issue_number: issueNumber,\n                state: 'closed',\n                state_reason: 'not_planned'\n              });\n              core.info(`Closed as duplicate of #${duplicateOf}`);\n            }\n\n      - name: 'Comment on failure'\n        if: failure()\n        uses: 'actions/github-script@v7'\n        env:\n          ISSUE_NUMBER: '${{ steps.get_issue.outputs.number || github.event.issue.number }}'\n          RUN_URL: '${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}'\n        with:\n          script: |\n            if (!process.env.ISSUE_NUMBER) return;\n            await github.rest.issues.createComment({\n              owner: context.repo.owner,\n              repo: context.repo.repo,\n              issue_number: parseInt(process.env.ISSUE_NUMBER),\n              body: `⚠️ Automated triage failed. [View logs](${process.env.RUN_URL})`\n            });\n"
  },
  {
    "path": ".github/workflows/release-beta.yml",
    "content": "name: Release Beta\n\non:\n  workflow_dispatch:\n    inputs:\n      force:\n        description: 'Force publish even if version unchanged'\n        required: false\n        default: false\n        type: boolean\n\npermissions:\n  contents: write\n  id-token: write\n\njobs:\n  publish-beta:\n    runs-on: ubuntu-latest\n    steps:\n      - name: Checkout\n        uses: actions/checkout@v4\n        with:\n          fetch-depth: 0\n          token: ${{ secrets.GITHUB_TOKEN }}\n\n      - name: Use Node.js\n        uses: actions/setup-node@v4\n        with:\n          node-version: 20\n          registry-url: https://registry.npmjs.org\n          always-auth: true\n\n      - name: Verify not on main branch\n        run: |\n          set -euo pipefail\n          CURRENT_BRANCH=\"${GITHUB_REF#refs/heads/}\"\n          if [ \"$CURRENT_BRANCH\" = \"main\" ]; then\n            echo \"ERROR: Beta release workflow should not run on main branch\" >&2\n            echo \"This workflow is for dev branch only\" >&2\n            exit 1\n          fi\n          echo \"Running on branch: $CURRENT_BRANCH\"\n\n      - name: Determine and bump beta version\n        id: determine\n        run: |\n          set -euo pipefail\n          \n          # Get base version from package.json (strip any existing prerelease suffix)\n          RAW_VERSION=$(node -p \"require('./package.json').version\")\n          BASE_VERSION=$(echo \"$RAW_VERSION\" | sed 's/-.*$//')\n          echo \"base_version=$BASE_VERSION\" >> \"$GITHUB_OUTPUT\"\n          \n          # Fetch all tags\n          git fetch --tags --force\n          \n          # Find the highest beta number for this base version\n          HIGHEST_BETA=-1\n          for tag in $(git tag -l \"v${BASE_VERSION}-beta.*\"); do\n            # Extract beta number from tag like v1.2.5-beta.3\n            BETA_NUM=$(echo \"$tag\" | sed \"s/v${BASE_VERSION}-beta\\\\.//\")\n            if [[ \"$BETA_NUM\" =~ ^[0-9]+$ ]] && [ \"$BETA_NUM\" -gt \"$HIGHEST_BETA\" ]; then\n              HIGHEST_BETA=$BETA_NUM\n            fi\n          done\n          \n          # Also check npm for published beta versions\n          NPM_BETAS=$(npm view opencode-antigravity-auth versions --json 2>/dev/null | grep -oP \"\\\"${BASE_VERSION}-beta\\\\.\\\\K[0-9]+\" || echo \"\")\n          for BETA_NUM in $NPM_BETAS; do\n            if [ \"$BETA_NUM\" -gt \"$HIGHEST_BETA\" ]; then\n              HIGHEST_BETA=$BETA_NUM\n            fi\n          done\n          \n          # Increment to next beta\n          NEXT_BETA=$((HIGHEST_BETA + 1))\n          NEXT_VERSION=\"${BASE_VERSION}-beta.${NEXT_BETA}\"\n          echo \"next_version=$NEXT_VERSION\" >> \"$GITHUB_OUTPUT\"\n          echo \"next_beta=$NEXT_BETA\" >> \"$GITHUB_OUTPUT\"\n          \n          echo \"Base version: $BASE_VERSION\"\n          echo \"Highest existing beta: $HIGHEST_BETA\"\n          echo \"Next beta version: $NEXT_VERSION\"\n\n      - name: Update package.json version\n        id: update_version\n        run: |\n          NEXT_VERSION=\"${{ steps.determine.outputs.next_version }}\"\n          \n          # Update package.json with new beta version\n          node -e \"\n            const fs = require('fs');\n            const pkg = JSON.parse(fs.readFileSync('package.json', 'utf8'));\n            pkg.version = '$NEXT_VERSION';\n            fs.writeFileSync('package.json', JSON.stringify(pkg, null, 4) + '\\n');\n          \"\n          \n          echo \"Updated package.json to version $NEXT_VERSION\"\n          \n          # Commit the version bump\n          git config user.name \"github-actions[bot]\"\n          git config user.email \"github-actions[bot]@users.noreply.github.com\"\n          git add package.json\n          git commit -m \"chore: bump version to $NEXT_VERSION [skip ci]\" || echo \"No changes to commit\"\n          git push origin dev || echo \"Push failed or no changes\"\n\n      - name: Check if should publish\n        id: should_publish\n        run: |\n          # Always publish since we auto-increment\n          echo \"should_publish=true\" >> \"$GITHUB_OUTPUT\"\n          echo \"Will publish version ${{ steps.determine.outputs.next_version }}\"\n\n      - name: Verify NPM token\n        if: steps.should_publish.outputs.should_publish == 'true'\n        run: |\n          if [ -z \"${NPM_TOKEN}\" ]; then\n            echo \"NPM_TOKEN secret is required\" >&2\n            exit 1\n          fi\n        env:\n          NPM_TOKEN: ${{ secrets.NPM_TOKEN }}\n\n      - name: Install dependencies\n        if: steps.should_publish.outputs.should_publish == 'true'\n        run: npm install\n\n      - name: Run type check\n        if: steps.should_publish.outputs.should_publish == 'true'\n        run: npm run typecheck\n\n      - name: Run tests\n        if: steps.should_publish.outputs.should_publish == 'true'\n        run: npm test\n\n      - name: Build\n        if: steps.should_publish.outputs.should_publish == 'true'\n        run: npm run build\n\n      - name: Verify build artifacts\n        if: steps.should_publish.outputs.should_publish == 'true'\n        run: |\n          set -euo pipefail\n          [ -f dist/index.js ] || { echo \"dist/index.js missing\" >&2; exit 1; }\n          [ -f dist/index.d.ts ] || { echo \"dist/index.d.ts missing\" >&2; exit 1; }\n          [ -d dist/src ] || { echo \"dist/src/ missing\" >&2; exit 1; }\n\n      - name: Create git tag\n        if: steps.should_publish.outputs.should_publish == 'true'\n        run: |\n          NEXT_VERSION=\"${{ steps.determine.outputs.next_version }}\"\n          git config user.name \"github-actions[bot]\"\n          git config user.email \"github-actions[bot]@users.noreply.github.com\"\n          git tag \"v$NEXT_VERSION\"\n          git push origin \"v$NEXT_VERSION\"\n\n      - name: Generate release notes\n        if: steps.should_publish.outputs.should_publish == 'true'\n        id: release_notes\n        run: |\n          set -euo pipefail\n          NEXT_VERSION=\"${{ steps.determine.outputs.next_version }}\"\n          BASE_VERSION=\"${{ steps.determine.outputs.base_version }}\"\n          \n          # Get commits since last tag\n          LAST_TAG=$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || echo \"\")\n          if [ -n \"$LAST_TAG\" ]; then\n            CHANGELOG=$(git log --no-merges --pretty=format:'- %s (%h)' \"${LAST_TAG}..HEAD\" | grep -v \"\\[skip ci\\]\" || echo \"\")\n            COMPARE_URL=\"https://github.com/${GITHUB_REPOSITORY}/compare/${LAST_TAG}...v${NEXT_VERSION}\"\n          else\n            CHANGELOG=$(git log --no-merges --pretty=format:'- %s (%h)' -20 | grep -v \"\\[skip ci\\]\" || echo \"\")\n            COMPARE_URL=\"\"\n          fi\n          \n          if [ -z \"$CHANGELOG\" ]; then\n            CHANGELOG=\"- Beta release ${NEXT_VERSION}\"\n          fi\n          \n          BODY_FILE=$(mktemp)\n          {\n            echo \"## Beta Release v${NEXT_VERSION}\"\n            echo \"\"\n            echo \"⚠️ **This is a beta release for testing. Use at your own risk.**\"\n            echo \"\"\n            echo \"Base version: \\`${BASE_VERSION}\\`\"\n            echo \"\"\n            if [ -n \"$COMPARE_URL\" ]; then\n              echo \"Compare changes: $COMPARE_URL\"\n              echo \"\"\n            fi\n            printf \"%s\\n\" \"$CHANGELOG\"\n            echo \"\"\n            echo \"### Install Beta\"\n            echo \"\"\n            echo \"Update your \\`opencode.json\\`:\"\n            echo \"\"\n            printf '%s\\n' '```json'\n            printf '%s\\n' '{'\n            printf '%s\\n' \"  \\\"plugin\\\": [\\\"opencode-antigravity-auth@${NEXT_VERSION}\\\"]\"\n            printf '%s\\n' '}'\n            printf '%s\\n' '```'\n            echo \"\"\n            echo \"Or use the beta tag (always latest beta):\"\n            echo \"\"\n            printf '%s\\n' '```json'\n            printf '%s\\n' '{'\n            printf '%s\\n' '  \"plugin\": [\"opencode-antigravity-auth@beta\"]'\n            printf '%s\\n' '}'\n            printf '%s\\n' '```'\n            echo \"\"\n            echo \"Clear cache if stuck on old version:\"\n            echo \"\"\n            printf '%s\\n' '```bash'\n            printf '%s\\n' 'rm -rf ~/.cache/opencode/node_modules ~/.cache/opencode/bun.lock'\n            printf '%s\\n' '```'\n          } >\"$BODY_FILE\"\n          \n          {\n            echo \"body<<EOF\"\n            cat \"$BODY_FILE\"\n            echo \"EOF\"\n          } >>\"$GITHUB_OUTPUT\"\n\n      - name: Create GitHub pre-release\n        if: steps.should_publish.outputs.should_publish == 'true'\n        uses: actions/create-release@v1\n        env:\n          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n        with:\n          tag_name: v${{ steps.determine.outputs.next_version }}\n          release_name: v${{ steps.determine.outputs.next_version }} (Beta)\n          body: ${{ steps.release_notes.outputs.body }}\n          prerelease: true\n          draft: false\n\n      - name: Publish to npm with beta tag\n        if: steps.should_publish.outputs.should_publish == 'true'\n        env:\n          NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}\n        run: npm publish --access public --tag beta --provenance\n\n      - name: Summary\n        if: steps.should_publish.outputs.should_publish == 'true'\n        run: |\n          echo \"## Beta Release Published! 🚀\" >> $GITHUB_STEP_SUMMARY\n          echo \"\" >> $GITHUB_STEP_SUMMARY\n          echo \"**Version:** ${{ steps.determine.outputs.next_version }}\" >> $GITHUB_STEP_SUMMARY\n          echo \"**Base:** ${{ steps.determine.outputs.base_version }}\" >> $GITHUB_STEP_SUMMARY\n          echo \"\" >> $GITHUB_STEP_SUMMARY\n          echo \"**Install:**\" >> $GITHUB_STEP_SUMMARY\n          echo '```json' >> $GITHUB_STEP_SUMMARY\n          echo '{ \"plugin\": [\"opencode-antigravity-auth@beta\"] }' >> $GITHUB_STEP_SUMMARY\n          echo '```' >> $GITHUB_STEP_SUMMARY\n"
  },
  {
    "path": ".github/workflows/release.yml",
    "content": "name: Release\n\non:\n  push:\n    branches:\n      - main\n  workflow_dispatch:\n\npermissions:\n  contents: write\n  id-token: write\n\njobs:\n  publish:\n    runs-on: ubuntu-latest\n    steps:\n      - name: Checkout\n        uses: actions/checkout@v4\n        with:\n          fetch-depth: 0\n\n      - name: Use Node.js\n        uses: actions/setup-node@v4\n        with:\n          node-version: 20\n          registry-url: https://registry.npmjs.org\n          always-auth: true\n\n      - name: Determine release state\n        id: determine\n        run: |\n          set -euo pipefail\n          CURRENT_VERSION=$(node -p \"require('./package.json').version\")\n          echo \"current_version=$CURRENT_VERSION\" >> \"$GITHUB_OUTPUT\"\n\n          # Abort if version contains beta/alpha/rc (prerelease identifiers)\n          if echo \"$CURRENT_VERSION\" | grep -qE '(beta|alpha|rc)'; then\n            echo \"ERROR: Cannot publish prerelease version '$CURRENT_VERSION' as official release\" >&2\n            echo \"This is main branch - version should not contain beta/alpha/rc\" >&2\n            exit 1\n          fi\n          if git rev-parse HEAD^ >/dev/null 2>&1; then\n            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) {}\")\n            PREVIOUS_VERSION=${PREVIOUS_VERSION//$'\\n'/}\n          else\n            PREVIOUS_VERSION=\"\"\n          fi\n          echo \"previous_version=$PREVIOUS_VERSION\" >> \"$GITHUB_OUTPUT\"\n          if [ \"$CURRENT_VERSION\" = \"$PREVIOUS_VERSION\" ]; then\n            echo \"changed=false\" >> \"$GITHUB_OUTPUT\"\n          else\n            echo \"changed=true\" >> \"$GITHUB_OUTPUT\"\n          fi\n          git fetch --tags --force\n          if git tag -l \"v$CURRENT_VERSION\" | grep -q \"v$CURRENT_VERSION\"; then\n            echo \"tag_exists=true\" >> \"$GITHUB_OUTPUT\"\n          else\n            echo \"tag_exists=false\" >> \"$GITHUB_OUTPUT\"\n          fi\n\n      - name: Verify NPM token\n        if: steps.determine.outputs.changed == 'true' && steps.determine.outputs.tag_exists == 'false'\n        run: |\n          if [ -z \"${NPM_TOKEN}\" ]; then\n            echo \"NPM_TOKEN secret is required\" >&2\n            exit 1\n          fi\n        env:\n          NPM_TOKEN: ${{ secrets.NPM_TOKEN }}\n\n      - name: Install dependencies\n        if: steps.determine.outputs.changed == 'true' && steps.determine.outputs.tag_exists == 'false'\n        run: npm install\n\n      - name: Run Tests\n        if: steps.determine.outputs.changed == 'true' && steps.determine.outputs.tag_exists == 'false'\n        run: npm test\n\n      - name: Update README and Tag\n        if: steps.determine.outputs.changed == 'true' && steps.determine.outputs.tag_exists == 'false'\n        id: update_readme\n        run: |\n          CURRENT_VERSION=\"${{ steps.determine.outputs.current_version }}\"\n          \n          git checkout main\n          git pull origin main\n          \n          # Update version in README.md (looking for pattern opencode-antigravity-auth@x.y.z)\n          sed -i \"s/opencode-antigravity-auth@[0-9]\\+\\.[0-9]\\+\\.[0-9]\\+/opencode-antigravity-auth@$CURRENT_VERSION/g\" README.md\n          \n          if ! git diff --quiet README.md; then\n            git config user.name \"github-actions[bot]\"\n            git config user.email \"github-actions[bot]@users.noreply.github.com\"\n            git add README.md\n            git commit -m \"docs: update readme version to $CURRENT_VERSION [skip ci]\"\n            git push origin main\n            echo \"README updated\"\n          else\n            echo \"README already up to date\"\n          fi\n          \n          # Create and push tag on the current HEAD\n          git tag \"v$CURRENT_VERSION\"\n          git push origin \"v$CURRENT_VERSION\"\n\n      - name: Build\n        if: steps.determine.outputs.changed == 'true' && steps.determine.outputs.tag_exists == 'false'\n        run: npm run build\n\n      - name: Verify build artifacts\n        if: steps.determine.outputs.changed == 'true' && steps.determine.outputs.tag_exists == 'false'\n        run: |\n          set -euo pipefail\n          [ -f dist/index.js ] || { echo \"dist/index.js missing\" >&2; exit 1; }\n          [ -f dist/index.d.ts ] || { echo \"dist/index.d.ts missing\" >&2; exit 1; }\n          [ -d dist/src ] || { echo \"dist/src/ missing\" >&2; exit 1; }\n\n      - name: Generate release notes\n        if: steps.determine.outputs.changed == 'true' && steps.determine.outputs.tag_exists == 'false'\n        id: release_notes\n        run: |\n          set -euo pipefail\n          CURRENT_VERSION=\"${{ steps.determine.outputs.current_version }}\"\n          PREVIOUS_VERSION=\"${{ steps.determine.outputs.previous_version }}\"\n          RANGE=\"\"\n          COMPARE_URL=\"\"\n          LAST_TAG=$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || true)\n          if [ -z \"$LAST_TAG\" ] && [ -n \"$PREVIOUS_VERSION\" ] && git rev-parse \"refs/tags/v${PREVIOUS_VERSION}\" >/dev/null 2>&1; then\n            LAST_TAG=\"v${PREVIOUS_VERSION}\"\n          fi\n          if [ -n \"$LAST_TAG\" ]; then\n            RANGE=\"${LAST_TAG}..HEAD\"\n            COMPARE_URL=\"https://github.com/${GITHUB_REPOSITORY}/compare/${LAST_TAG}...v${CURRENT_VERSION}\"\n          fi\n          if [ -n \"$RANGE\" ]; then\n            CHANGELOG=$(git log --no-merges --pretty=format:'- %s (%h)' \"$RANGE\")\n          else\n            CHANGELOG=$(git log --no-merges --pretty=format:'- %s (%h)')\n          fi\n          if [ -z \"$CHANGELOG\" ]; then\n            CHANGELOG=\"- No commits found for this release.\"\n          fi\n          BODY_FILE=$(mktemp)\n          {\n            echo \"## Release v${CURRENT_VERSION}\"\n            echo \"\"\n            if [ -n \"$COMPARE_URL\" ]; then\n              echo \"Compare changes: $COMPARE_URL\"\n              echo \"\"\n            fi\n            printf \"%s\\n\" \"$CHANGELOG\"\n            echo \"\"\n            echo \"### Upgrade\"\n            echo \"\"\n            echo \"Update your \\`opencode.json\\`:\"\n            echo \"\"\n            printf '%s\\n' '```json'\n            printf '%s\\n' '{'\n            printf '%s\\n' \"  \\\"plugins\\\": [\\\"opencode-antigravity-auth@${CURRENT_VERSION}\\\"]\"\n            printf '%s\\n' '}'\n            printf '%s\\n' '```'\n            echo \"\"\n            echo \"If stuck on an old version, clear the cache:\"\n            echo \"\"\n            printf '%s\\n' '```bash'\n            printf '%s\\n' 'rm -rf ~/.cache/opencode/node_modules ~/.cache/opencode/bun.lock'\n            printf '%s\\n' '```'\n\n          } >\"$BODY_FILE\"\n          cat \"$BODY_FILE\"\n          {\n            echo \"body<<EOF\"\n            cat \"$BODY_FILE\"\n            echo \"EOF\"\n          } >>\"$GITHUB_OUTPUT\"\n\n      - name: Create GitHub release\n        if: steps.determine.outputs.changed == 'true' && steps.determine.outputs.tag_exists == 'false'\n        uses: actions/create-release@v1\n        env:\n          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n        with:\n          tag_name: v${{ steps.determine.outputs.current_version }}\n          release_name: v${{ steps.determine.outputs.current_version }}\n          body: ${{ steps.release_notes.outputs.body }}\n          generate_release_notes: false\n\n      - name: Publish to npm\n        if: steps.determine.outputs.changed == 'true' && steps.determine.outputs.tag_exists == 'false'\n        env:\n          NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}\n        run: npm publish --access public --provenance\n"
  },
  {
    "path": ".github/workflows/republish-version.yml",
    "content": "name: Republish Version\n\non:\n  workflow_dispatch:\n    inputs:\n      version:\n        description: 'Version to republish (e.g., 1.2.5)'\n        required: true\n        type: string\n      skip_unpublish:\n        description: 'Skip unpublish step (if version does not exist on npm)'\n        required: false\n        default: false\n        type: boolean\n\npermissions:\n  contents: write\n  id-token: write\n\njobs:\n  republish:\n    runs-on: ubuntu-latest\n    steps:\n      - name: Checkout\n        uses: actions/checkout@v4\n        with:\n          fetch-depth: 0\n\n      - name: Use Node.js\n        uses: actions/setup-node@v4\n        with:\n          node-version: 20\n          registry-url: https://registry.npmjs.org\n          always-auth: true\n\n      - name: Verify version matches package.json\n        run: |\n          set -euo pipefail\n          REQUESTED_VERSION=\"${{ github.event.inputs.version }}\"\n          PACKAGE_VERSION=$(node -p \"require('./package.json').version\")\n\n          if [ \"$REQUESTED_VERSION\" != \"$PACKAGE_VERSION\" ]; then\n            echo \"ERROR: Requested version ($REQUESTED_VERSION) does not match package.json ($PACKAGE_VERSION)\" >&2\n            echo \"Please update package.json to the version you want to publish\" >&2\n            exit 1\n          fi\n\n          echo \"Version verified: $PACKAGE_VERSION\"\n\n      - name: Unpublish existing version\n        if: github.event.inputs.skip_unpublish != 'true'\n        env:\n          NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}\n        run: |\n          set -euo pipefail\n          VERSION=\"${{ github.event.inputs.version }}\"\n\n          echo \"Checking if version $VERSION exists on npm...\"\n          if npm view opencode-antigravity-auth@$VERSION version >/dev/null 2>&1; then\n            echo \"Version $VERSION exists, unpublishing...\"\n            npm unpublish opencode-antigravity-auth@$VERSION --force\n            echo \"Successfully unpublished version $VERSION\"\n          else\n            echo \"Version $VERSION does not exist on npm, skipping unpublish\"\n          fi\n\n      - name: Delete existing git tag\n        run: |\n          set -euo pipefail\n          VERSION=\"${{ github.event.inputs.version }}\"\n          TAG=\"v$VERSION\"\n\n          echo \"Checking if tag $TAG exists...\"\n          if git tag -l \"$TAG\" | grep -q \"$TAG\"; then\n            echo \"Tag $TAG exists locally, deleting...\"\n            git tag -d \"$TAG\" || true\n          fi\n\n          if git ls-remote --tags origin | grep -q \"refs/tags/$TAG\"; then\n            echo \"Tag $TAG exists remotely, deleting...\"\n            git push origin :refs/tags/$TAG || true\n          fi\n\n          echo \"Git tag cleanup complete\"\n\n      - name: Verify NPM token\n        run: |\n          if [ -z \"${NPM_TOKEN}\" ]; then\n            echo \"NPM_TOKEN secret is required\" >&2\n            exit 1\n          fi\n        env:\n          NPM_TOKEN: ${{ secrets.NPM_TOKEN }}\n\n      - name: Install dependencies\n        run: npm install\n\n      - name: Run type check\n        run: npm run typecheck\n\n      - name: Run tests\n        run: npm test\n\n      - name: Build\n        run: npm run build\n\n      - name: Verify build artifacts\n        run: |\n          set -euo pipefail\n          [ -f dist/index.js ] || { echo \"dist/index.js missing\" >&2; exit 1; }\n          [ -f dist/index.d.ts ] || { echo \"dist/index.d.ts missing\" >&2; exit 1; }\n          [ -d dist/src ] || { echo \"dist/src/ missing\" >&2; exit 1; }\n\n      - name: Publish to npm\n        env:\n          NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}\n        run: |\n          set -euo pipefail\n          VERSION=\"${{ github.event.inputs.version }}\"\n\n          echo \"Publishing version $VERSION to npm...\"\n          npm publish --access public --provenance\n\n          echo \"Successfully published version $VERSION\"\n          echo \"\"\n          echo \"Updating @latest tag...\"\n          npm dist-tag add opencode-antigravity-auth@$VERSION latest\n\n          echo \"\"\n          echo \"Current dist-tags:\"\n          npm dist-tag ls opencode-antigravity-auth\n\n      - name: Create git tag\n        run: |\n          VERSION=\"${{ github.event.inputs.version }}\"\n          git config user.name \"github-actions[bot]\"\n          git config user.email \"github-actions[bot]@users.noreply.github.com\"\n          git tag \"v$VERSION\"\n          git push origin \"v$VERSION\"\n\n      - name: Generate release notes\n        id: release_notes\n        run: |\n          set -euo pipefail\n          VERSION=\"${{ github.event.inputs.version }}\"\n\n          LAST_TAG=$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || echo \"\")\n          if [ -n \"$LAST_TAG\" ]; then\n            CHANGELOG=$(git log --no-merges --pretty=format:'- %s (%h)' \"${LAST_TAG}..HEAD\" | grep -v \"\\[skip ci\\]\" || echo \"\")\n            COMPARE_URL=\"https://github.com/${GITHUB_REPOSITORY}/compare/${LAST_TAG}...v${VERSION}\"\n          else\n            CHANGELOG=$(git log --no-merges --pretty=format:'- %s (%h)' -20 | grep -v \"\\[skip ci\\]\" || echo \"\")\n            COMPARE_URL=\"\"\n          fi\n\n          if [ -z \"$CHANGELOG\" ]; then\n            CHANGELOG=\"- Release v${VERSION}\"\n          fi\n\n          BODY_FILE=$(mktemp)\n          {\n            echo \"## Release v${VERSION}\"\n            echo \"\"\n            if [ -n \"$COMPARE_URL\" ]; then\n              echo \"Compare changes: $COMPARE_URL\"\n              echo \"\"\n            fi\n            printf \"%s\\n\" \"$CHANGELOG\"\n            echo \"\"\n            echo \"### Install\"\n            echo \"\"\n            echo \"Update your \\`opencode.json\\`:\"\n            echo \"\"\n            printf '%s\\n' '```json'\n            printf '%s\\n' '{'\n            printf '%s\\n' \"  \\\"plugins\\\": [\\\"opencode-antigravity-auth@${VERSION}\\\"]\"\n            printf '%s\\n' '}'\n            printf '%s\\n' '```'\n          } >\"$BODY_FILE\"\n\n          {\n            echo \"body<<EOF\"\n            cat \"$BODY_FILE\"\n            echo \"EOF\"\n          } >>\"$GITHUB_OUTPUT\"\n\n      - name: Create GitHub release\n        uses: actions/create-release@v1\n        env:\n          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n        with:\n          tag_name: v${{ github.event.inputs.version }}\n          release_name: v${{ github.event.inputs.version }}\n          body: ${{ steps.release_notes.outputs.body }}\n          draft: false\n          prerelease: false\n\n      - name: Summary\n        run: |\n          echo \"## Version Republished! 🚀\" >> $GITHUB_STEP_SUMMARY\n          echo \"\" >> $GITHUB_STEP_SUMMARY\n          echo \"**Version:** ${{ github.event.inputs.version }}\" >> $GITHUB_STEP_SUMMARY\n          echo \"\" >> $GITHUB_STEP_SUMMARY\n          echo \"**Install:**\" >> $GITHUB_STEP_SUMMARY\n          echo '```bash' >> $GITHUB_STEP_SUMMARY\n          echo \"npm install opencode-antigravity-auth@${{ github.event.inputs.version }}\" >> $GITHUB_STEP_SUMMARY\n          echo \"# or\" >> $GITHUB_STEP_SUMMARY\n          echo \"npm install opencode-antigravity-auth@latest\" >> $GITHUB_STEP_SUMMARY\n          echo '```' >> $GITHUB_STEP_SUMMARY\n"
  },
  {
    "path": ".github/workflows/update-dist-tag.yml",
    "content": "name: Update NPM Dist Tag\n\non:\n  workflow_dispatch:\n    inputs:\n      version:\n        description: 'Version to tag (e.g., 1.2.5)'\n        required: true\n        type: string\n      tag:\n        description: 'Dist tag to update (e.g., latest, beta)'\n        required: true\n        type: choice\n        options:\n          - latest\n          - beta\n          - next\n          - canary\n\npermissions:\n  contents: read\n  id-token: write\n\njobs:\n  update-tag:\n    runs-on: ubuntu-latest\n    steps:\n      - name: Checkout\n        uses: actions/checkout@v4\n\n      - name: Use Node.js\n        uses: actions/setup-node@v4\n        with:\n          node-version: 20\n          registry-url: https://registry.npmjs.org\n          always-auth: true\n\n      - name: Validate version exists\n        run: |\n          set -euo pipefail\n          VERSION=\"${{ github.event.inputs.version }}\"\n          TAG=\"${{ github.event.inputs.tag }}\"\n\n          echo \"Checking if version $VERSION exists on npm...\"\n          if ! npm view opencode-antigravity-auth@$VERSION version >/dev/null 2>&1; then\n            echo \"ERROR: Version $VERSION does not exist on npm\" >&2\n            echo \"Available versions:\" >&2\n            npm view opencode-antigravity-auth versions --json | tail -20 >&2\n            exit 1\n          fi\n\n          echo \"Version $VERSION exists on npm\"\n          echo \"Current dist-tags:\"\n          npm dist-tag ls opencode-antigravity-auth\n\n      - name: Update dist-tag\n        env:\n          NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}\n        run: |\n          set -euo pipefail\n          VERSION=\"${{ github.event.inputs.version }}\"\n          TAG=\"${{ github.event.inputs.tag }}\"\n\n          echo \"Updating @$TAG tag to version $VERSION...\"\n          npm dist-tag add opencode-antigravity-auth@$VERSION $TAG\n\n          echo \"\"\n          echo \"Updated dist-tags:\"\n          npm dist-tag ls opencode-antigravity-auth\n\n      - name: Summary\n        run: |\n          echo \"## NPM Dist Tag Updated! 🏷️\" >> $GITHUB_STEP_SUMMARY\n          echo \"\" >> $GITHUB_STEP_SUMMARY\n          echo \"**Version:** ${{ github.event.inputs.version }}\" >> $GITHUB_STEP_SUMMARY\n          echo \"**Tag:** @${{ github.event.inputs.tag }}\" >> $GITHUB_STEP_SUMMARY\n          echo \"\" >> $GITHUB_STEP_SUMMARY\n          echo \"Users can now install with:\" >> $GITHUB_STEP_SUMMARY\n          echo '```bash' >> $GITHUB_STEP_SUMMARY\n          echo \"npm install opencode-antigravity-auth@${{ github.event.inputs.tag }}\" >> $GITHUB_STEP_SUMMARY\n          echo '```' >> $GITHUB_STEP_SUMMARY\n"
  },
  {
    "path": ".gitignore",
    "content": "# dependencies (bun install)\nnode_modules\n\n# output\nout\ndist\n*.tgz\n\n# code coverage\ncoverage\n*.lcov\n\n# logs\nlogs\n_.log\nreport.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json\nantigravity-debug-*.log\n\n# dotenv environment variable files\n.env\n.env.development.local\n.env.test.local\n.env.production.local\n.env.local\n\n# caches\n.eslintcache\n.cache\n*.tsbuildinfo\n\n# IntelliJ based IDEs\n.idea\n\n# Finder (MacOS) folder config\n.DS_Store\n\n# Hive workflow internal files\n.hive/\n\n# Test artifacts\ntest-file.ts\n\n# Local subrepos (not part of this project)\nCLIProxyAPI/\nLLM-API-Key-Proxy/\nopencode/\nopencode-better-antigravity-auth/\n"
  },
  {
    "path": "AGENTS.MD",
    "content": "# AGENTS.md\n\nGuidance for AI agents working with this repository.\n\n## Overview\n\nOpenCode 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.\n\n## Build & Test Commands\n\n```bash\nnpm install                          # Install dependencies\nnpm run build                        # Compile (tsc -p tsconfig.build.json)\nnpm run typecheck                    # Type-check only (tsc --noEmit)\nnpm test                             # Run all tests (vitest run)\nnpx vitest run src/plugin/auth.test.ts          # Single test file\nnpx vitest run -t \"test name here\"              # Single test by name\nnpx vitest --watch src/plugin/auth.test.ts      # Watch mode, single file\nnpm run test:coverage                # Coverage report\nnpm run test:e2e:models              # E2E: model availability check\nnpm run test:e2e:regression          # E2E: regression suite\n```\n\nNo linter or formatter is configured. Style is enforced by convention (see below).\n\n## TypeScript Configuration\n\n- `strict: true` with extra strictness: `noUncheckedIndexedAccess`, `noImplicitOverride`, `noFallthroughCasesInSwitch`\n- `verbatimModuleSyntax: true` — use `import type` for type-only imports\n- `target: ESNext`, `module: Preserve`, `moduleResolution: bundler`\n- `allowImportingTsExtensions: true` — use `.ts` extensions in imports\n- No path aliases — all imports are relative\n\n## Code Style\n\n### Imports\n- Use `import type { ... }` for type-only imports (enforced by `verbatimModuleSyntax`)\n- Named imports only — no default imports in src/\n- Relative paths with `.ts` extensions: `import { foo } from \"./bar.ts\"`\n- Order: node builtins > external packages > local modules\n\n### Exports\n- Named exports only in src/ — no default exports\n- Barrel files (index.ts) for module surfaces\n\n### Naming\n- `camelCase` for functions, variables, parameters\n- `PascalCase` for types, interfaces, classes, enums\n- `UPPER_SNAKE_CASE` for constants\n- `kebab-case` for file names (e.g., `request-helpers.ts`, `thinking-recovery.ts`)\n- Test files: `*.test.ts` colocated with source\n\n### Types\n- No `I` prefix on interfaces, no `Type` suffix\n- Use `z.infer<typeof Schema>` for Zod-derived types\n- Extract to `types.ts` when shared, inline when local\n- Discriminated unions preferred over boolean flags\n- Never use `as any`, `@ts-ignore`, or `@ts-expect-error`\n\n### Functions\n- `export function` for public APIs\n- Arrow functions for callbacks, factories, and inline closures\n- Async functions with targeted try/catch (not blanket)\n\n### Error Handling\n- Defensive try/catch with graceful degradation (fallback values, not crashes)\n- Custom error classes with metadata when domain-specific\n- Catch `unknown`, log, and convert to domain errors — never empty catch blocks\n- Rate limit / quota errors trigger account rotation, not failure\n\n### Formatting\n- 2-space indentation\n- Double quotes for strings\n- Trailing commas in multiline constructs\n- No semicolons (project convention)\n\n### Logging\n- `createLogger(\"module-name\")` for structured logging\n- `console.log` only for CLI/user-facing output\n\n## Module Structure\n\n```\nsrc/\n├── plugin.ts                # Main entry, fetch interceptor\n├── constants.ts             # Endpoints, headers, API config, system prompts\n├── antigravity/oauth.ts     # OAuth token exchange\n└── plugin/\n    ├── auth.ts              # Token validation & refresh\n    ├── request.ts           # Request transformation (core logic)\n    ├── request-helpers.ts   # Schema cleaning, thinking filters\n    ├── thinking-recovery.ts # Turn boundary detection\n    ├── recovery.ts          # Session recovery (tool_result_missing)\n    ├── quota.ts             # Quota checking (API usage stats)\n    ├── cache.ts             # Auth & signature caching\n    ├── accounts.ts          # Multi-account management & storage\n    ├── storage.ts           # Persistent storage schemas (Zod)\n    ├── fingerprint.ts       # Device fingerprint generation & headers\n    ├── project.ts           # Managed project context resolution\n    └── debug.ts             # Debug logging utilities\n```\n\n## Key Design Patterns\n\n### 1. Request Interception\nPlugin intercepts `fetch()` for `generativelanguage.googleapis.com`, transforms to Antigravity format. Two header styles: `antigravity` (Electron-style UA + fingerprint) and `gemini-cli` (nodejs-client UA).\n\n### 2. Claude Thinking Blocks\nALL thinking blocks are stripped from outgoing requests for Claude models. Claude generates fresh thinking each turn. This eliminates signature validation errors.\n\n### 3. Session Recovery\nWhen tool execution is interrupted (ESC/timeout), the plugin injects synthetic `tool_result` blocks to recover the session without starting over.\n\n### 4. Schema Sanitization\nTool schemas are cleaned via allowlist. Unsupported fields (`const`, `$ref`, `$defs`) are removed or converted to Antigravity-compatible format.\n\n### 5. Multi-Account Load Balancing\nAccounts rotate on rate limits. Gemini has dual quota pools (Antigravity headers + Gemini CLI headers). Fingerprints are per-account and regenerated on capacity exhaustion.\n\n### 6. Fingerprint System\nPer-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.\n\n## Dependencies\n\n- `zod ^4` — schema validation (NOT zod v3)\n- `@opencode-ai/plugin` — OpenCode plugin interface\n- `@openauthjs/openauth` — OAuth client\n- `proper-lockfile` — file locking for concurrent access\n- `xdg-basedir` — XDG directory resolution\n\n## Testing\n\n- Framework: **Vitest 3** with native ESM\n- Config: `vitest.config.ts`\n- Tests colocated: `src/plugin/foo.test.ts` next to `src/plugin/foo.ts`\n- Use `describe`/`it`/`expect` — standard Vitest API\n- Mock with `vi.fn()`, `vi.spyOn()`, `vi.mock()`\n\n## Documentation\n\n- [README.md](README.md) — Installation & usage\n- [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) — Detailed architecture guide\n- [docs/ANTIGRAVITY_API_SPEC.md](docs/ANTIGRAVITY_API_SPEC.md) — API reference\n- [CHANGELOG.md](CHANGELOG.md) — Version history\n"
  },
  {
    "path": "CHANGELOG.md",
    "content": "# Changelog\n\n## [1.6.0] - 2026-02-20\n\n### Fixed\n\n- **#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.\n\n- **#454** - Request sanitization now removes empty/invalid `contents.parts` entries and invalid `systemInstruction.parts` before forwarding to Antigravity.\n\n- **#444** - Response transform fallback now uses cloned responses and preserves recovery signaling, eliminating `Body already used` failures.\n\n- **#368 (Tackled)** - Claude thinking/signature handling now replaces foreign signatures with sentinels and tightens thinking-order classification to reduce false-positive recovery triggers.\n\n### Changed\n\n- **Debug Sink Split** - `debug` now controls file logging only, while `debug_tui` independently controls TUI panel logging.\n\n- **Header Normalization** - `x-goog-user-project` is now stripped across Antigravity and Gemini CLI request styles.\n\n- **Claude Prompt Auto-Caching (Optional)** - Added `claude_prompt_auto_caching` to inject `cache_control: { type: \"ephemeral\" }` when Claude prompt caching is desired and unset.\n\n### Documentation\n\n- Updated README, architecture/config/troubleshooting docs, and generated schema docs to reflect new debug sink semantics and config keys.\n\n## [1.5.2] - 2026-02-18\n\n### Changed\n\n- Added support for Sonnet 4.6 and removed old models support.\n\n## [1.5.1] - 2026-02-11\n\n### Changed\n\n- **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\n\n- **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\n\n- **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\n\n- **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\n\n- **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\n\n### Removed\n\n- **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)\n\n- **`getAntigravityUserAgents()` Function** - Removed unused helper that had no callers in the codebase\n\n- **`X-Opencode-Tools-Debug` Header** - Removed debug telemetry header from outgoing requests\n\n## [1.5.0] - 2026-02-11\n\n### Added\n\n- **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\n\n- **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\n\n- **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\n\n- **`saveAccountsReplace`** - New destructive-write storage function that replaces the entire accounts file without merging, preventing deleted accounts from being resurrected by concurrent reads\n\n- **`setAccountEnabled` / Account Toggling** - New account management methods: `setAccountEnabled()`, `markAccountVerificationRequired()`, `clearAccountVerificationRequired()`, `removeAccountByIndex()`\n\n- **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\n\n- **`opencode.jsonc` Support** - Configure models flow now detects and prefers existing `opencode.jsonc` files. JSONC parsing strips comments and trailing commas before JSON.parse\n\n- **Header Contract Tests** - New `src/constants.test.ts` validates header shapes, randomization behavior, and optional header fields for both Antigravity and Gemini CLI styles\n\n### Changed\n\n- **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)\n\n- **`cli_first` Honored in Routing** - `resolveHeaderRoutingDecision()` centralizes routing logic and properly respects `cli_first` for unsuffixed Gemini models\n\n- **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\n\n- **Client Metadata Reduced** - Fingerprint client metadata trimmed to `ideType`, `platform`, `pluginType` only. Removed `osVersion`, `arch`, `sqmId`\n\n- **Gemini CLI User-Agent Format** - Updated from `google-genai-sdk/...` to `GeminiCLI/...` format\n\n- **Search Model** - Changed from `gemini-2.0-flash` to `gemini-2.5-flash` for improved search result quality\n\n- **Deterministic Search Generation** - Search requests now use `temperature: 0` and `topP: 1` instead of thinking config\n\n- **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\n\n### Fixed\n\n- **#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\n- **#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\n- **#381**: Disabled accounts no longer selected via sticky index. `getCurrentAccountForFamily()` now skips disabled accounts and advances the active index\n- **#384**: `google_search` tool no longer returns empty citations when using `gemini-3-flash`. Search model switched to `gemini-2.5-flash`\n- **#377**: Configure models flow now respects existing `opencode.jsonc` files instead of creating duplicate `opencode.json`\n- **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\n- **Fingerprint Alignment** - Force-regenerated fingerprints to match current Antigravity Manager behavior, fixing `ideType` and stripping stale client metadata fields\n\n### Removed\n\n- **Extra Outgoing Headers** - `X-Goog-Api-Client`, `Client-Metadata`, `X-Goog-QuotaUser`, `X-Client-Device-Id` no longer sent on content requests\n- **Fingerprint Metadata Fields** - `osVersion`, `arch`, `sqmId` removed from fingerprint client metadata\n- **`updateFingerprintVersion` Helper** - Removed from accounts module (fingerprint version rewriting no longer needed)\n\n### Documentation\n\n- **AGENTS.md** expanded with detailed architecture, code style, and fingerprint system documentation\n- **README.md**, **CONFIGURATION.md**, **MULTI-ACCOUNT.md** updated to reflect deprecated `quota_fallback` and automatic Gemini pool fallback behavior\n- **`antigravity.schema.json`** marks `quota_fallback` as deprecated/ignored\n\n## [1.4.5] - 2026-02-05\n\n### Added\n\n- **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\n\n- **`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\n\n- **`toast_scope` Configuration** - Control toast visibility per session with `toast_scope: \"root_only\"` to suppress toasts in subagent sessions\n\n- **Soft Quota Protection** - Skip accounts over 90% usage threshold to prevent Google penalties, with configurable `soft_quota_threshold_percent` and wait/retry behavior\n\n- **Gemini CLI Quota Management** - Enhanced quota display with dual quota pool support (Antigravity + Gemini CLI)\n\n- **`OPENCODE_CONFIG_DIR` Environment Variable** - Custom config location support for non-standard setups\n\n- **`quota_refresh_interval_minutes`** - Background quota cache refresh (default 15 minutes)\n\n- **`soft_quota_cache_ttl_minutes`** - Cache freshness control for soft quota checks\n\n### Changed\n\n- **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`)\n\n- **Antigravity-First Quota Strategy** - Exhausts Antigravity quota across ALL accounts before falling back to Gemini CLI quota (previously per-account)\n\n- **Quota Routing Respects `cli_first`** - Fallback behavior updated to respect `cli_first` preference\n\n- **Config Directory Resolution** - Now prioritizes `OPENCODE_CONFIG_DIR` environment variable\n\n- **Enhanced Debug Logging** - Process ID included for better traceability across concurrent sessions\n\n- **Improved Quota Group Resolution** - More consistent quota management with `resolveQuotaGroup` function\n\n### Fixed\n\n- **#337**: Skip disabled accounts in proactive token refresh\n- **#233**: Skip sandbox endpoints for Gemini CLI models (fixes 404/403 cascade)\n- **Windows Config Auto-Migration**: Automatically migrates config from `%APPDATA%\\opencode\\` to `~/.config/opencode/`\n- **Root Session Detection**: Reset `isChildSession` flag correctly for root sessions\n- **Stale Quota Cache**: Prevent spin loop on stale quota cache\n- **Quota Group Default**: Fix quota group selection defaulting to `gemini-pro` when model is null\n\n### Removed\n\n- **Fingerprint Headers for Gemini CLI** - Removed fingerprint headers from Gemini CLI model requests to align with official behavior\n- **`web_search` Configuration Leftovers** - Cleaned up remaining `web_search` config remnants from schema\n\n### Documentation\n\n- Updated README with model configuration options and simplified setup instructions\n- Updated MODEL-VARIANTS.md with Antigravity model names and configuration guidance\n- Updated CONFIGURATION.md to clarify `quota_fallback` behavior across accounts\n- Updated MULTI-ACCOUNT.md with dual quota pool and fallback flow details\n\n---\n\n## [1.3.2] - 2026-01-27\n\n### Added\n\n- **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))\n\n- **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\n\n- **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\n\n- **Per-account fingerprint persistence** - Fingerprints are now persisted per-account in storage, allowing consistent identity across sessions and enabling fingerprint history tracking\n  - Added fingerprint restore operations to AccountManager\n  - Extended per-account fingerprint history for better tracking\n  - Fingerprint now shown in debug output\n\n- **Scheduling mode configuration** - Added new scheduling modes including `cache-first` mode that prioritizes accounts with cached tokens, reducing authentication overhead\n\n- **Failure count TTL expiration** - Account failure counts now expire after a configurable time period, allowing accounts to naturally recover from temporary issues\n\n- **Exponential backoff for 503/529 errors** - Implemented exponential backoff with jitter for capacity-related errors, matching behavior of Antigravity-Manager\n\n### Changed\n\n- **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\n\n- **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\n\n- **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\n\n### Fixed\n\n- **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))\n\n- **`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\n\n- **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\n\n- **Filter disabled accounts in all selection methods** - Ensured disabled accounts are properly excluded from all account selection strategies (round-robin, least-used, random, etc.)\n\n- **Robust handling for capacity/5xx errors** - Implemented comprehensive retry logic for model capacity and server errors, achieving parity with Antigravity-Manager's behavior\n  - Reordered parsing logic to prioritize capacity checks\n  - Fixed loop retry logic to prevent state pollution\n  - Added capacity retry limit to prevent infinite loops ([#263](https://github.com/NoeFabris/opencode-antigravity-auth/issues/263))\n\n- **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\n\n### Removed\n\n- **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)\n\n## [1.3.1] - 2026-01-21\n\n### Added\n\n- **New `google_search` tool for web search** - Implements Google Search grounding as a callable tool that the model can invoke explicitly\n  - Makes separate API calls with only `{ googleSearch: {} }` tool, avoiding Gemini API limitation where grounding tools cannot be combined with function declarations\n  - Returns formatted markdown with search results, sources with URLs, and search queries used\n  - Supports optional URL analysis via `urlContext` when URLs are provided\n  - Configurable thinking mode (deep vs fast) for search operations\n  - Uses `gemini-3-flash` model for fast, cost-effective search operations\n\n### Changed\n\n- Upgraded to Zod v4 and adjusted schema generation for compatibility\n- **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\n\n### Fixed\n\n- **`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\n  - Root cause: `filterContentArray` trusted any signature >= 50 chars for last assistant messages, but Claude returns its own signatures that Antigravity doesn't recognize\n  - Fix: Now verifies signatures against our cache via `isOurCachedSignature()` before passing through. Foreign/missing signatures get replaced with `SKIP_THOUGHT_SIGNATURE` sentinel\n  - Why debug worked: Debug mode injects synthetic thinking with no signature, triggering sentinel injection correctly\n\n- **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`\n  - 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`\n  - Fix: Changed condition to handle all `functionCall` parts, defaulting `args` to `{}` when missing, ensuring opencode's `state.input` always receives a valid record\n\n- **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))\n  - `GEMINI_CLI_HEADERS[\"User-Agent\"]`: `9.15.1` → `10.3.0`\n  - `GEMINI_CLI_HEADERS[\"X-Goog-Api-Client\"]`: `gl-node/22.17.0` → `gl-node/22.18.0`\n  - `ANTIGRAVITY_HEADERS[\"User-Agent\"]`: Updated to full Chrome/Electron user agent string\n  - Token exchange now includes `Accept`, `Accept-Encoding`, `User-Agent`, `X-Goog-Api-Client` headers\n  - Userinfo fetch now includes `User-Agent`, `X-Goog-Api-Client` headers\n  - `fetchProjectID` now uses centralized constants instead of hardcoded strings\n\n- **`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))\n  - Root cause: The `showToast` helper function didn't check `quietMode`, and only some call sites had manual `!quietMode &&` guards\n  - Fix: Moved `quietMode` check inside `showToast` helper so all toasts are automatically suppressed when `quiet_mode: true`\n\n### Removed\n\n- **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\n\n## [1.3.0] - Previous Release\n\nSee [releases](https://github.com/NoeFabris/opencode-antigravity-auth/releases) for previous versions.\n"
  },
  {
    "path": "LICENSE",
    "content": "MIT License\n\nCopyright (c) 2025 Jens\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n"
  },
  {
    "path": "README.md",
    "content": "# Antigravity + Gemini CLI OAuth Plugin for Opencode\n\n[![npm version](https://img.shields.io/npm/v/opencode-antigravity-auth.svg)](https://www.npmjs.com/package/opencode-antigravity-auth)\n[![npm beta](https://img.shields.io/npm/v/opencode-antigravity-auth/beta.svg?label=beta)](https://www.npmjs.com/package/opencode-antigravity-auth)\n[![npm downloads](https://img.shields.io/npm/dw/opencode-antigravity-auth.svg)](https://www.npmjs.com/package/opencode-antigravity-auth)\n[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)\n[![X (Twitter)](https://img.shields.io/badge/X-@dopesalmon-000000?style=flat&logo=x)](https://x.com/dopesalmon)\n\nEnable 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.\n\n## What You Get\n\n- **Claude Opus 4.6, Sonnet 4.6** and **Gemini 3.1 Pro/Flash** via Google OAuth\n- **Multi-account support** — add multiple Google accounts, auto-rotates when rate-limited\n- **Dual quota system** — access both Antigravity and Gemini CLI quotas from one plugin\n- **Thinking models** — extended thinking for Claude and Gemini 3 with configurable budgets\n- **Google Search grounding** — enable web search for Gemini models (auto or always-on)\n- **Auto-recovery** — handles session errors and tool failures automatically\n- **Plugin compatible** — works alongside other OpenCode plugins (oh-my-opencode, dcp, etc.)\n\n---\n\n<details open>\n<summary><b>⚠️ Terms of Service Warning — Read Before Installing</b></summary>\n\n> [!CAUTION]\n> 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).\n>\n> **By using this plugin, you acknowledge:**\n> - This is an unofficial tool not endorsed by Google\n> - Your account may be suspended or permanently banned\n> - You assume all risks associated with using this plugin\n>\n\n</details>\n\n---\n\n## Installation\n\n<details open>\n<summary><b>For Humans</b></summary>\n\n**Option A: Let an LLM do it**\n\nPaste this into any LLM agent (Claude Code, OpenCode, Cursor, etc.):\n\n```\nInstall 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\n```\n\n**Option B: Manual setup**\n\n1. **Add the plugin** to `~/.config/opencode/opencode.json`:\n\n   ```json\n   {\n     \"plugin\": [\"opencode-antigravity-auth@latest\"]\n   }\n   ```\n\n   > Want bleeding-edge features? Use `opencode-antigravity-auth@beta` instead.\n\n2. **Login** with your Google account:\n\n   ```bash\n   opencode auth login\n   ```\n\n3. **Add models** — choose one:\n   - Run `opencode auth login` → Google → OAuth with Google (Antigravity) → select **\"Configure models in opencode.json\"** (auto-configures all models)\n   - Or manually copy the [full configuration](#models) below\n\n4. **Use it:**\n\n   ```bash\n   opencode run \"Hello\" --model=google/antigravity-claude-opus-4-6-thinking --variant=max\n   ```\n\n</details>\n\n<details>\n<summary><b>For LLM Agents</b></summary>\n\n### Step-by-Step Instructions\n\n1. Edit the OpenCode configuration file at `~/.config/opencode/opencode.json`\n   \n   > **Note**: This path works on all platforms. On Windows, `~` resolves to your user home directory (e.g., `C:\\Users\\YourName`).\n\n2. Add the plugin to the `plugin` array\n\n3. Add the model definitions from the [Full models configuration](#models) section\n\n4. Set `provider` to `\"google\"` and choose a model\n\n### Verification\n\n```bash\nopencode run \"Hello\" --model=google/antigravity-claude-opus-4-6-thinking --variant=max\n```\n\n</details>\n\n---\n\n## Models\n\n### Model Reference\n\n**Antigravity quota** (default routing for Claude and Gemini):\n\n| Model | Variants | Notes |\n|-------|----------|-------|\n| `antigravity-gemini-3-pro` | low, high | Gemini 3 Pro with thinking |\n| `antigravity-gemini-3.1-pro` | low, high | Gemini 3.1 Pro with thinking (rollout-dependent) |\n| `antigravity-gemini-3-flash` | minimal, low, medium, high | Gemini 3 Flash with thinking |\n| `antigravity-claude-sonnet-4-6` | — | Claude Sonnet 4.6 |\n| `antigravity-claude-opus-4-6-thinking` | low, max | Claude Opus 4.6 with extended thinking |\n\n**Gemini CLI quota** (separate from Antigravity; used when `cli_first` is true or as fallback):\n\n| Model | Notes |\n|-------|-------|\n| `gemini-2.5-flash` | Gemini 2.5 Flash |\n| `gemini-2.5-pro` | Gemini 2.5 Pro |\n| `gemini-3-flash-preview` | Gemini 3 Flash (preview) |\n| `gemini-3-pro-preview` | Gemini 3 Pro (preview) |\n| `gemini-3.1-pro-preview` | Gemini 3.1 Pro (preview, rollout-dependent) |\n| `gemini-3.1-pro-preview-customtools` | Gemini 3.1 Pro Preview Custom Tools (preview, rollout-dependent) |\n\n> **Routing Behavior:**\n> - **Antigravity-first (default):** Gemini models use Antigravity quota across accounts.\n> - **CLI-first (`cli_first: true`):** Gemini models use Gemini CLI quota first.\n> - When a Gemini quota pool is exhausted, the plugin automatically falls back to the other pool.\n> - Claude and image models always use Antigravity.\n> Model names are automatically transformed for the target API (e.g., `antigravity-gemini-3-flash` → `gemini-3-flash-preview` for CLI).\n\n**Using variants:**\n```bash\nopencode run \"Hello\" --model=google/antigravity-claude-opus-4-6-thinking --variant=max\n```\n\nFor details on variant configuration and thinking levels, see [docs/MODEL-VARIANTS.md](docs/MODEL-VARIANTS.md).\n\n<details>\n<summary><b>Full models configuration (copy-paste ready)</b></summary>\n\nAdd this to your `~/.config/opencode/opencode.json`:\n\n```json\n{\n  \"$schema\": \"https://opencode.ai/config.json\",\n  \"plugin\": [\"opencode-antigravity-auth@latest\"],\n  \"provider\": {\n    \"google\": {\n      \"models\": {\n        \"antigravity-gemini-3-pro\": {\n          \"name\": \"Gemini 3 Pro (Antigravity)\",\n          \"limit\": { \"context\": 1048576, \"output\": 65535 },\n          \"modalities\": { \"input\": [\"text\", \"image\", \"pdf\"], \"output\": [\"text\"] },\n          \"variants\": {\n            \"low\": { \"thinkingLevel\": \"low\" },\n            \"high\": { \"thinkingLevel\": \"high\" }\n          }\n        },\n        \"antigravity-gemini-3.1-pro\": {\n          \"name\": \"Gemini 3.1 Pro (Antigravity)\",\n          \"limit\": { \"context\": 1048576, \"output\": 65535 },\n          \"modalities\": { \"input\": [\"text\", \"image\", \"pdf\"], \"output\": [\"text\"] },\n          \"variants\": {\n            \"low\": { \"thinkingLevel\": \"low\" },\n            \"high\": { \"thinkingLevel\": \"high\" }\n          }\n        },\n        \"antigravity-gemini-3-flash\": {\n          \"name\": \"Gemini 3 Flash (Antigravity)\",\n          \"limit\": { \"context\": 1048576, \"output\": 65536 },\n          \"modalities\": { \"input\": [\"text\", \"image\", \"pdf\"], \"output\": [\"text\"] },\n          \"variants\": {\n            \"minimal\": { \"thinkingLevel\": \"minimal\" },\n            \"low\": { \"thinkingLevel\": \"low\" },\n            \"medium\": { \"thinkingLevel\": \"medium\" },\n            \"high\": { \"thinkingLevel\": \"high\" }\n          }\n        },\n        \"antigravity-claude-sonnet-4-6\": {\n          \"name\": \"Claude Sonnet 4.6 (Antigravity)\",\n          \"limit\": { \"context\": 200000, \"output\": 64000 },\n          \"modalities\": { \"input\": [\"text\", \"image\", \"pdf\"], \"output\": [\"text\"] }\n        },\n        \"antigravity-claude-opus-4-6-thinking\": {\n          \"name\": \"Claude Opus 4.6 Thinking (Antigravity)\",\n          \"limit\": { \"context\": 200000, \"output\": 64000 },\n          \"modalities\": { \"input\": [\"text\", \"image\", \"pdf\"], \"output\": [\"text\"] },\n          \"variants\": {\n            \"low\": { \"thinkingConfig\": { \"thinkingBudget\": 8192 } },\n            \"max\": { \"thinkingConfig\": { \"thinkingBudget\": 32768 } }\n          }\n        },\n        \"gemini-2.5-flash\": {\n          \"name\": \"Gemini 2.5 Flash (Gemini CLI)\",\n          \"limit\": { \"context\": 1048576, \"output\": 65536 },\n          \"modalities\": { \"input\": [\"text\", \"image\", \"pdf\"], \"output\": [\"text\"] }\n        },\n        \"gemini-2.5-pro\": {\n          \"name\": \"Gemini 2.5 Pro (Gemini CLI)\",\n          \"limit\": { \"context\": 1048576, \"output\": 65536 },\n          \"modalities\": { \"input\": [\"text\", \"image\", \"pdf\"], \"output\": [\"text\"] }\n        },\n        \"gemini-3-flash-preview\": {\n          \"name\": \"Gemini 3 Flash Preview (Gemini CLI)\",\n          \"limit\": { \"context\": 1048576, \"output\": 65536 },\n          \"modalities\": { \"input\": [\"text\", \"image\", \"pdf\"], \"output\": [\"text\"] }\n        },\n        \"gemini-3-pro-preview\": {\n          \"name\": \"Gemini 3 Pro Preview (Gemini CLI)\",\n          \"limit\": { \"context\": 1048576, \"output\": 65535 },\n          \"modalities\": { \"input\": [\"text\", \"image\", \"pdf\"], \"output\": [\"text\"] }\n        },\n        \"gemini-3.1-pro-preview\": {\n          \"name\": \"Gemini 3.1 Pro Preview (Gemini CLI)\",\n          \"limit\": { \"context\": 1048576, \"output\": 65535 },\n          \"modalities\": { \"input\": [\"text\", \"image\", \"pdf\"], \"output\": [\"text\"] }\n        },\n        \"gemini-3.1-pro-preview-customtools\": {\n          \"name\": \"Gemini 3.1 Pro Preview Custom Tools (Gemini CLI)\",\n          \"limit\": { \"context\": 1048576, \"output\": 65535 },\n          \"modalities\": { \"input\": [\"text\", \"image\", \"pdf\"], \"output\": [\"text\"] }\n        }\n      }\n    }\n  }\n}\n```\n\n> **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.\n\n</details>\n\n---\n\n## Multi-Account Setup\n\nAdd multiple Google accounts for higher combined quotas. The plugin automatically rotates between accounts when one is rate-limited.\n\n```bash\nopencode auth login  # Run again to add more accounts\n```\n\n**Account management options (via `opencode auth login`):**\n- **Configure models** — Auto-configure all plugin models in opencode.json\n- **Check quotas** — View remaining API quota for each account\n- **Manage accounts** — Enable/disable specific accounts for rotation\n\nFor details on load balancing, dual quota pools, and account storage, see [docs/MULTI-ACCOUNT.md](docs/MULTI-ACCOUNT.md).\n\n---\n\n## Troubleshoot\n\n> **Quick Reset**: Most issues can be resolved by deleting `~/.config/opencode/antigravity-accounts.json` and running `opencode auth login` again.\n\n### Configuration Path (All Platforms)\n\nOpenCode uses `~/.config/opencode/` on **all platforms** including Windows.\n\n| File | Path |\n|------|------|\n| Main config | `~/.config/opencode/opencode.json` |\n| Accounts | `~/.config/opencode/antigravity-accounts.json` |\n| Plugin config | `~/.config/opencode/antigravity.json` |\n| Debug logs | `~/.config/opencode/antigravity-logs/` |\n\n> **Windows users**: `~` resolves to your user home directory (e.g., `C:\\Users\\YourName`). Do NOT use `%APPDATA%`.\n\n> **Custom path**: Set `OPENCODE_CONFIG_DIR` environment variable to use a custom location.\n\n> **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/`.\n\n---\n\n### Multi-Account Auth Issues\n\nIf you encounter authentication issues with multiple accounts:\n\n1. Delete the accounts file:\n   ```bash\n   rm ~/.config/opencode/antigravity-accounts.json\n   ```\n2. Re-authenticate:\n   ```bash\n   opencode auth login\n   ```\n\n---\n\n### 403 Permission Denied (`rising-fact-p41fc`)\n\n**Error:**\n```\nPermission 'cloudaicompanion.companions.generateChat' denied on resource \n'//cloudaicompanion.googleapis.com/projects/rising-fact-p41fc/locations/global'\n```\n\n**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.\n\n**Solution:**\n1. Go to [Google Cloud Console](https://console.cloud.google.com/)\n2. Create or select a project\n3. Enable the **Gemini for Google Cloud API** (`cloudaicompanion.googleapis.com`)\n4. Add `projectId` to your accounts file:\n   ```json\n   {\n     \"accounts\": [\n       {\n         \"email\": \"your@email.com\",\n         \"refreshToken\": \"...\",\n         \"projectId\": \"your-project-id\"\n       }\n     ]\n   }\n   ```\n\n> **Note**: Do this for each account in a multi-account setup.\n\n---\n\n### Gemini Model Not Found\n\nAdd this to your `google` provider config:\n\n```json\n{\n  \"provider\": {\n    \"google\": {\n      \"npm\": \"@ai-sdk/google\",\n      \"models\": { ... }\n    }\n  }\n}\n```\n\n---\n\n### Gemini 3 Models 400 Error (\"Unknown name 'parameters'\")\n\n**Error:**\n```\nInvalid JSON payload received. Unknown name \"parameters\" at 'request.tools[0]'\n```\n\n**Causes:**\n- Tool schema incompatibility with Gemini's strict protobuf validation\n- MCP servers with malformed schemas\n- Plugin version regression\n\n**Solutions:**\n1. **Update to latest beta:**\n   ```json\n   { \"plugin\": [\"opencode-antigravity-auth@beta\"] }\n   ```\n\n2. **Disable MCP servers** one-by-one to find the problematic one\n\n3. **Add npm override:**\n   ```json\n   { \"provider\": { \"google\": { \"npm\": \"@ai-sdk/google\" } } }\n   ```\n\n---\n\n### MCP Servers Causing Errors\n\nSome MCP servers have schemas incompatible with Antigravity's strict JSON format.\n\n**Common symptom:**\n```bash\nInvalid function name must start with a letter or underscore\n```\n\nSometimes it shows up as:\n```bash\nGenerateContentRequest.tools[0].function_declarations[12].name: Invalid function name must start with a letter or underscore\n```\n\nThis 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.\n\n**Diagnosis:**\n1. Disable all MCP servers in your config\n2. Enable one-by-one until error reappears\n3. Report the specific MCP in a [GitHub issue](https://github.com/NoeFabris/opencode-antigravity-auth/issues)\n\n---\n\n### \"All Accounts Rate-Limited\" (But Quota Available)\n\n**Cause:** Cascade bug in `clearExpiredRateLimits()` in hybrid mode (fixed in recent beta).\n\n**Solutions:**\n1. Update to latest beta version\n2. If persists, delete accounts file and re-authenticate\n3. Try switching `account_selection_strategy` to `\"sticky\"` in `antigravity.json`\n\n---\n\n### Session Recovery\n\nIf you encounter errors during a session:\n1. Type `continue` to trigger the recovery mechanism\n2. If blocked, use `/undo` to revert to pre-error state\n3. Retry the operation\n\n---\n\n### Using with Oh-My-OpenCode\n\n**Important:** Disable the built-in Google auth to prevent conflicts:\n\n```json\n// ~/.config/opencode/oh-my-opencode.json\n{\n  \"google_auth\": false,\n  \"agents\": {\n    \"frontend-ui-ux-engineer\": { \"model\": \"google/antigravity-gemini-3-pro\" },\n    \"document-writer\": { \"model\": \"google/antigravity-gemini-3-flash\" }\n  }\n}\n```\n\n---\n\n### Infinite `.tmp` Files Created\n\n**Cause:** When account is rate-limited and plugin retries infinitely, it creates many temp files.\n\n**Workaround:**\n1. Stop OpenCode\n2. Clean up: `rm ~/.config/opencode/*.tmp`\n3. Add more accounts or wait for rate limit to expire\n\n---\n\n### OAuth Callback Issues\n\n<details>\n<summary><b>Safari OAuth Callback Fails (macOS)</b></summary>\n\n**Symptoms:**\n- \"fail to authorize\" after successful Google login\n- Safari shows \"Safari can't open the page\"\n\n**Cause:** Safari's \"HTTPS-Only Mode\" blocks `http://localhost` callback.\n\n**Solutions:**\n\n1. **Use Chrome or Firefox** (easiest):\n   Copy the OAuth URL and paste into a different browser.\n\n2. **Disable HTTPS-Only Mode temporarily:**\n   - Safari > Settings (⌘,) > Privacy\n   - Uncheck \"Enable HTTPS-Only Mode\"\n   - Run `opencode auth login`\n   - Re-enable after authentication\n\n</details>\n\n<details>\n<summary><b>Port Conflict (Address Already in Use)</b></summary>\n\n**macOS / Linux:**\n```bash\n# Find process using the port\nlsof -i :51121\n\n# Kill if stale\nkill -9 <PID>\n\n# Retry\nopencode auth login\n```\n\n**Windows (PowerShell):**\n```powershell\nnetstat -ano | findstr :51121\ntaskkill /PID <PID> /F\nopencode auth login\n```\n\n</details>\n\n<details>\n<summary><b>Docker / WSL2 / Remote Development</b></summary>\n\nOAuth callback requires browser to reach `localhost` on the machine running OpenCode.\n\n**WSL2:**\n- Use VS Code's port forwarding, or\n- Configure Windows → WSL port forwarding\n\n**SSH / Remote:**\n```bash\nssh -L 51121:localhost:51121 user@remote\n```\n\n**Docker / Containers:**\n- OAuth with localhost redirect doesn't work in containers\n- Wait 30s for manual URL flow, or use SSH port forwarding\n\n</details>\n\n---\n\n### Configuration Key Typo: `plugin` not `plugins`\n\nThe correct key is `plugin` (singular):\n\n```json\n{\n  \"plugin\": [\"opencode-antigravity-auth@beta\"]\n}\n```\n\n**Not** `\"plugins\"` (will cause \"Unrecognized key\" error).\n\n---\n\n### Migrating Accounts Between Machines\n\nWhen copying `antigravity-accounts.json` to a new machine:\n1. Ensure the plugin is installed: `\"plugin\": [\"opencode-antigravity-auth@beta\"]`\n2. Copy `~/.config/opencode/antigravity-accounts.json`\n3. If you get \"API key missing\" error, the refresh token may be invalid — re-authenticate\n\n## Known Plugin Interactions\nFor details on load balancing, dual quota pools, and account storage, see [docs/MULTI-ACCOUNT.md](docs/MULTI-ACCOUNT.md).\n\n---\n\n## Plugin Compatibility\n\n### @tarquinen/opencode-dcp\n\nDCP creates synthetic assistant messages that lack thinking blocks. **List this plugin BEFORE DCP:**\n\n```json\n{\n  \"plugin\": [\n    \"opencode-antigravity-auth@latest\",\n    \"@tarquinen/opencode-dcp@latest\"\n  ]\n}\n```\n\n### oh-my-opencode\n\nDisable built-in auth and override agent models in `oh-my-opencode.json`:\n\n```json\n{\n  \"google_auth\": false,\n  \"agents\": {\n    \"frontend-ui-ux-engineer\": { \"model\": \"google/antigravity-gemini-3-pro\" },\n    \"document-writer\": { \"model\": \"google/antigravity-gemini-3-flash\" },\n    \"multimodal-looker\": { \"model\": \"google/antigravity-gemini-3-flash\" }\n  }\n}\n```\n\n> **Tip:** When spawning parallel subagents, enable `pid_offset_enabled: true` in `antigravity.json` to distribute sessions across accounts.\n\n### Plugins you don't need\n\n- **gemini-auth plugins** — Not needed. This plugin handles all Google OAuth.\n\n---\n\n## Configuration\n\nCreate `~/.config/opencode/antigravity.json` for optional settings:\n\n```json\n{\n  \"$schema\": \"https://raw.githubusercontent.com/NoeFabris/opencode-antigravity-auth/main/assets/antigravity.schema.json\"\n}\n```\n\nMost users don't need to configure anything — defaults work well.\n\n### Model Behavior\n\n| Option | Default | What it does |\n|--------|---------|--------------\n| `keep_thinking` | `false` | Preserve Claude's thinking across turns. **Warning:** enabling may degrade model stability. |\n| `session_recovery` | `true` | Auto-recover from tool errors |\n| `cli_first` | `false` | Route Gemini models to Gemini CLI first (Claude and image models stay on Antigravity). |\n\n### Account Rotation\n\n| Your Setup | Recommended Config |\n|------------|-------------------|\n| **1 account** | `\"account_selection_strategy\": \"sticky\"` |\n| **2-5 accounts** | Default (`\"hybrid\"`) works great |\n| **5+ accounts** | `\"account_selection_strategy\": \"round-robin\"` |\n| **Parallel agents** | Add `\"pid_offset_enabled\": true` |\n\n### Quota Protection\n\n| Option | Default | What it does |\n|--------|---------|--------------|\n| `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. |\n| `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. |\n| `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. |\n\n> **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.\n\n### Rate Limit Scheduling\n\nControl how the plugin handles rate limits:\n\n| Option | Default | What it does |\n|--------|---------|--------------|\n| `scheduling_mode` | `\"cache_first\"` | `\"cache_first\"` = wait for same account (preserves prompt cache), `\"balance\"` = switch immediately, `\"performance_first\"` = round-robin |\n| `max_cache_first_wait_seconds` | `60` | Max seconds to wait in cache_first mode before switching accounts |\n| `failure_ttl_seconds` | `3600` | Reset failure count after this many seconds (prevents old failures from permanently penalizing accounts) |\n\n**When to use each mode:**\n- **cache_first** (default): Best for long conversations. Waits for the same account to recover, preserving your prompt cache.\n- **balance**: Best for quick tasks. Switches accounts immediately when rate-limited for maximum availability.\n- **performance_first**: Best for many short requests. Distributes load evenly across all accounts.\n\n### App Behavior\n\n| Option | Default | What it does |\n|--------|---------|--------------|\n| `quiet_mode` | `false` | Hide toast notifications |\n| `debug` | `false` | Enable debug file logging (`~/.config/opencode/antigravity-logs/`) |\n| `debug_tui` | `false` | Show debug logs in the TUI log panel (independent from `debug`) |\n| `auto_update` | `true` | Auto-update plugin |\n\nFor all options, see [docs/CONFIGURATION.md](docs/CONFIGURATION.md).\n\n**Environment variables:**\n```bash\nOPENCODE_CONFIG_DIR=/path/to/config opencode  # Custom config directory\nOPENCODE_ANTIGRAVITY_DEBUG=1 opencode         # Enable debug file logging\nOPENCODE_ANTIGRAVITY_DEBUG=2 opencode         # Verbose debug file logging\nOPENCODE_ANTIGRAVITY_DEBUG_TUI=1 opencode     # Enable TUI log panel debug output\n```\n\n---\n\n## Troubleshooting\n\nSee the full [Troubleshooting Guide](docs/TROUBLESHOOTING.md) for solutions to common issues including:\n\n- Auth problems and token refresh\n- \"Model not found\" errors\n- Session recovery\n- Gemini CLI permission errors\n- Safari OAuth issues\n- Plugin compatibility\n- Migration guides\n\n---\n\n## Documentation\n\n- [Configuration](docs/CONFIGURATION.md) — All configuration options\n- [Multi-Account](docs/MULTI-ACCOUNT.md) — Load balancing, dual quota pools, account storage\n- [Model Variants](docs/MODEL-VARIANTS.md) — Thinking budgets and variant system\n- [Troubleshooting](docs/TROUBLESHOOTING.md) — Common issues and fixes\n- [Architecture](docs/ARCHITECTURE.md) — How the plugin works\n- [API Spec](docs/ANTIGRAVITY_API_SPEC.md) — Antigravity API reference\n\n---\n\n## Support\n\nIf this plugin helps you, consider supporting its maintenance:\n\n[![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/S6S81QBOIR)\n\n---\n\n## Credits\n\n- [opencode-gemini-auth](https://github.com/jenslys/opencode-gemini-auth) by [@jenslys](https://github.com/jenslys)\n- [CLIProxyAPI](https://github.com/router-for-me/CLIProxyAPI)\n\n## License\n\nMIT License. See [LICENSE](LICENSE) for details.\n\n<details>\n<summary><b>Legal</b></summary>\n\n### Intended Use\n\n- Personal / internal development only\n- Respect internal quotas and data handling policies\n- Not for production services or bypassing intended limits\n\n### Warning\n\nBy using this plugin, you acknowledge:\n\n- **Terms of Service risk** — This approach may violate ToS of AI model providers\n- **Account risk** — Providers may suspend or ban accounts\n- **No guarantees** — APIs may change without notice\n- **Assumption of risk** — You assume all legal, financial, and technical risks\n\n### Disclaimer\n\n- Not affiliated with Google. This is an independent open-source project.\n- \"Antigravity\", \"Gemini\", \"Google Cloud\", and \"Google\" are trademarks of Google LLC.\n\n</details>\n"
  },
  {
    "path": "assets/antigravity.schema.json",
    "content": "{\n  \"$schema\": \"https://json-schema.org/draft/2020-12/schema\",\n  \"type\": \"object\",\n  \"properties\": {\n    \"$schema\": {\n      \"type\": \"string\"\n    },\n    \"quiet_mode\": {\n      \"default\": false,\n      \"type\": \"boolean\",\n      \"description\": \"Suppress most toast notifications (rate limit, account switching). Recovery toasts always shown.\"\n    },\n    \"toast_scope\": {\n      \"default\": \"root_only\",\n      \"type\": \"string\",\n      \"enum\": [\n        \"root_only\",\n        \"all\"\n      ],\n      \"description\": \"Controls which sessions show toast notifications. 'root_only' (default) shows in root session only, 'all' shows in all sessions.\"\n    },\n    \"debug\": {\n      \"default\": false,\n      \"type\": \"boolean\",\n      \"description\": \"Enable debug logging to file.\"\n    },\n    \"debug_tui\": {\n      \"default\": false,\n      \"type\": \"boolean\"\n    },\n    \"log_dir\": {\n      \"type\": \"string\",\n      \"description\": \"Custom directory for debug logs.\"\n    },\n    \"keep_thinking\": {\n      \"default\": false,\n      \"type\": \"boolean\",\n      \"description\": \"Preserve thinking blocks for Claude models using signature caching. May cause signature errors.\"\n    },\n    \"session_recovery\": {\n      \"default\": true,\n      \"type\": \"boolean\",\n      \"description\": \"Enable automatic session recovery from tool_result_missing errors.\"\n    },\n    \"auto_resume\": {\n      \"default\": false,\n      \"type\": \"boolean\",\n      \"description\": \"Automatically send resume prompt after successful recovery.\"\n    },\n    \"resume_text\": {\n      \"default\": \"continue\",\n      \"type\": \"string\",\n      \"description\": \"Custom text to send when auto-resuming after recovery.\"\n    },\n    \"signature_cache\": {\n      \"type\": \"object\",\n      \"properties\": {\n        \"enabled\": {\n          \"default\": true,\n          \"type\": \"boolean\",\n          \"description\": \"Enable disk caching of thinking block signatures.\"\n        },\n        \"memory_ttl_seconds\": {\n          \"default\": 3600,\n          \"type\": \"number\",\n          \"minimum\": 60,\n          \"maximum\": 86400,\n          \"description\": \"In-memory TTL in seconds.\"\n        },\n        \"disk_ttl_seconds\": {\n          \"default\": 172800,\n          \"type\": \"number\",\n          \"minimum\": 3600,\n          \"maximum\": 604800,\n          \"description\": \"Disk TTL in seconds.\"\n        },\n        \"write_interval_seconds\": {\n          \"default\": 60,\n          \"type\": \"number\",\n          \"minimum\": 10,\n          \"maximum\": 600,\n          \"description\": \"Background write interval in seconds.\"\n        }\n      },\n      \"required\": [\n        \"enabled\",\n        \"memory_ttl_seconds\",\n        \"disk_ttl_seconds\",\n        \"write_interval_seconds\"\n      ],\n      \"additionalProperties\": false,\n      \"description\": \"Signature cache configuration for persisting thinking block signatures. Only used when keep_thinking is enabled.\"\n    },\n    \"empty_response_max_attempts\": {\n      \"default\": 4,\n      \"type\": \"number\",\n      \"minimum\": 1,\n      \"maximum\": 10,\n      \"description\": \"Maximum retry attempts when Antigravity returns an empty response (no candidates).\"\n    },\n    \"empty_response_retry_delay_ms\": {\n      \"default\": 2000,\n      \"type\": \"number\",\n      \"minimum\": 500,\n      \"maximum\": 10000,\n      \"description\": \"Delay in milliseconds between empty response retries.\"\n    },\n    \"tool_id_recovery\": {\n      \"default\": true,\n      \"type\": \"boolean\",\n      \"description\": \"Enable tool ID orphan recovery. Matches mismatched tool responses by function name or creates placeholders.\"\n    },\n    \"claude_tool_hardening\": {\n      \"default\": true,\n      \"type\": \"boolean\",\n      \"description\": \"Enable tool hallucination prevention for Claude models. Injects parameter signatures and strict usage rules.\"\n    },\n    \"claude_prompt_auto_caching\": {\n      \"default\": false,\n      \"type\": \"boolean\",\n      \"description\": \"Enable Claude prompt auto-caching by adding top-level cache_control when absent.\"\n    },\n    \"proactive_token_refresh\": {\n      \"default\": true,\n      \"type\": \"boolean\",\n      \"description\": \"Enable proactive background token refresh before expiry, ensuring requests never block.\"\n    },\n    \"proactive_refresh_buffer_seconds\": {\n      \"default\": 1800,\n      \"type\": \"number\",\n      \"minimum\": 60,\n      \"maximum\": 7200,\n      \"description\": \"Seconds before token expiry to trigger proactive refresh.\"\n    },\n    \"proactive_refresh_check_interval_seconds\": {\n      \"default\": 300,\n      \"type\": \"number\",\n      \"minimum\": 30,\n      \"maximum\": 1800,\n      \"description\": \"Interval between proactive refresh checks in seconds.\"\n    },\n    \"max_rate_limit_wait_seconds\": {\n      \"default\": 300,\n      \"type\": \"number\",\n      \"minimum\": 0,\n      \"maximum\": 3600\n    },\n    \"quota_fallback\": {\n      \"default\": false,\n      \"type\": \"boolean\",\n      \"description\": \"Deprecated: accepted for backward compatibility but ignored at runtime. Gemini fallback between Antigravity and Gemini CLI is always enabled.\"\n    },\n    \"cli_first\": {\n      \"default\": false,\n      \"type\": \"boolean\",\n      \"description\": \"Prefer gemini-cli routing before Antigravity for Gemini models. When false (default), Antigravity is tried first and gemini-cli is fallback.\"\n    },\n    \"account_selection_strategy\": {\n      \"default\": \"hybrid\",\n      \"type\": \"string\",\n      \"enum\": [\n        \"sticky\",\n        \"round-robin\",\n        \"hybrid\"\n      ]\n    },\n    \"pid_offset_enabled\": {\n      \"default\": false,\n      \"type\": \"boolean\"\n    },\n    \"switch_on_first_rate_limit\": {\n      \"default\": true,\n      \"type\": \"boolean\"\n    },\n    \"scheduling_mode\": {\n      \"default\": \"cache_first\",\n      \"type\": \"string\",\n      \"enum\": [\n        \"cache_first\",\n        \"balance\",\n        \"performance_first\"\n      ],\n      \"description\": \"Rate limit scheduling strategy. 'cache_first' (default) waits for cooldowns, 'balance' distributes across accounts, 'performance_first' picks fastest available.\"\n    },\n    \"max_cache_first_wait_seconds\": {\n      \"default\": 60,\n      \"type\": \"number\",\n      \"minimum\": 5,\n      \"maximum\": 300,\n      \"description\": \"Maximum seconds to wait for a rate-limited account in cache_first mode before switching.\"\n    },\n    \"failure_ttl_seconds\": {\n      \"default\": 3600,\n      \"type\": \"number\",\n      \"minimum\": 60,\n      \"maximum\": 7200,\n      \"description\": \"Time in seconds before a failed account is eligible for retry.\"\n    },\n    \"default_retry_after_seconds\": {\n      \"default\": 60,\n      \"type\": \"number\",\n      \"minimum\": 1,\n      \"maximum\": 300\n    },\n    \"max_backoff_seconds\": {\n      \"default\": 60,\n      \"type\": \"number\",\n      \"minimum\": 5,\n      \"maximum\": 300\n    },\n    \"request_jitter_max_ms\": {\n      \"default\": 0,\n      \"type\": \"number\",\n      \"minimum\": 0,\n      \"maximum\": 5000,\n      \"description\": \"Maximum random jitter in milliseconds added to outgoing requests to avoid thundering herd.\"\n    },\n    \"soft_quota_threshold_percent\": {\n      \"default\": 90,\n      \"type\": \"number\",\n      \"minimum\": 1,\n      \"maximum\": 100,\n      \"description\": \"Percentage of quota usage that triggers soft quota warnings and preemptive account switching.\"\n    },\n    \"quota_refresh_interval_minutes\": {\n      \"default\": 15,\n      \"type\": \"number\",\n      \"minimum\": 0,\n      \"maximum\": 60,\n      \"description\": \"Interval in minutes between quota usage checks. Set to 0 to disable periodic checks.\"\n    },\n    \"soft_quota_cache_ttl_minutes\": {\n      \"default\": \"auto\",\n      \"anyOf\": [\n        {\n          \"type\": \"string\",\n          \"const\": \"auto\"\n        },\n        {\n          \"type\": \"number\",\n          \"minimum\": 1,\n          \"maximum\": 120\n        }\n      ],\n      \"description\": \"TTL for cached soft quota data. 'auto' (default) calculates from refresh interval, or set a fixed number of minutes.\"\n    },\n    \"health_score\": {\n      \"type\": \"object\",\n      \"properties\": {\n        \"initial\": {\n          \"default\": 70,\n          \"type\": \"number\",\n          \"minimum\": 0,\n          \"maximum\": 100\n        },\n        \"success_reward\": {\n          \"default\": 1,\n          \"type\": \"number\",\n          \"minimum\": 0,\n          \"maximum\": 10\n        },\n        \"rate_limit_penalty\": {\n          \"default\": -10,\n          \"type\": \"number\",\n          \"minimum\": -50,\n          \"maximum\": 0\n        },\n        \"failure_penalty\": {\n          \"default\": -20,\n          \"type\": \"number\",\n          \"minimum\": -100,\n          \"maximum\": 0\n        },\n        \"recovery_rate_per_hour\": {\n          \"default\": 2,\n          \"type\": \"number\",\n          \"minimum\": 0,\n          \"maximum\": 20\n        },\n        \"min_usable\": {\n          \"default\": 50,\n          \"type\": \"number\",\n          \"minimum\": 0,\n          \"maximum\": 100\n        },\n        \"max_score\": {\n          \"default\": 100,\n          \"type\": \"number\",\n          \"minimum\": 50,\n          \"maximum\": 100\n        }\n      },\n      \"required\": [\n        \"initial\",\n        \"success_reward\",\n        \"rate_limit_penalty\",\n        \"failure_penalty\",\n        \"recovery_rate_per_hour\",\n        \"min_usable\",\n        \"max_score\"\n      ],\n      \"additionalProperties\": false\n    },\n    \"token_bucket\": {\n      \"type\": \"object\",\n      \"properties\": {\n        \"max_tokens\": {\n          \"default\": 50,\n          \"type\": \"number\",\n          \"minimum\": 1,\n          \"maximum\": 1000\n        },\n        \"regeneration_rate_per_minute\": {\n          \"default\": 6,\n          \"type\": \"number\",\n          \"minimum\": 0.1,\n          \"maximum\": 60\n        },\n        \"initial_tokens\": {\n          \"default\": 50,\n          \"type\": \"number\",\n          \"minimum\": 1,\n          \"maximum\": 1000\n        }\n      },\n      \"required\": [\n        \"max_tokens\",\n        \"regeneration_rate_per_minute\",\n        \"initial_tokens\"\n      ],\n      \"additionalProperties\": false\n    },\n    \"auto_update\": {\n      \"default\": true,\n      \"type\": \"boolean\",\n      \"description\": \"Enable automatic plugin updates.\"\n    }\n  },\n  \"additionalProperties\": false\n}\n"
  },
  {
    "path": "docs/ANTIGRAVITY_API_SPEC.md",
    "content": "# Antigravity Unified Gateway API Specification\n\n**Version:** 1.0\n**Last Updated:** December 13, 2025\n**Status:** Verified by Direct API Testing\n\n---\n\n## Overview\n\nAntigravity 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.\n\n### Key Characteristics\n\n- **Single API format** for all models (Gemini-style)\n- **Project-based access** via Google Cloud authentication\n- **Internal routing** to model backends (Vertex AI for Claude, Gemini API for Gemini)\n- **Unified response format** (`candidates[]` structure for all models)\n\n---\n\n## Endpoints\n\n| Environment | URL | Status |\n|-------------|-----|--------|\n| **Daily (Sandbox)** | `https://daily-cloudcode-pa.sandbox.googleapis.com` | ✅ Active |\n| **Production** | `https://cloudcode-pa.googleapis.com` | ✅ Active |\n| **Autopush (Sandbox)** | `https://autopush-cloudcode-pa.sandbox.googleapis.com` | ❌ Unavailable |\n\n### API Actions\n\n| Action | Path | Description |\n|--------|------|-------------|\n| Generate Content | `/v1internal:generateContent` | Non-streaming request |\n| Stream Generate | `/v1internal:streamGenerateContent?alt=sse` | Streaming (SSE) request |\n| Load Code Assist | `/v1internal:loadCodeAssist` | Project discovery |\n| Onboard User | `/v1internal:onboardUser` | User onboarding |\n\n---\n\n## Authentication\n\n### OAuth 2.0 Setup\n\n```\nAuthorization URL: https://accounts.google.com/o/oauth2/auth\nToken URL: https://oauth2.googleapis.com/token\n```\n\n### Required Scopes\n\n```\nhttps://www.googleapis.com/auth/cloud-platform\nhttps://www.googleapis.com/auth/userinfo.email\nhttps://www.googleapis.com/auth/userinfo.profile\nhttps://www.googleapis.com/auth/cclog\nhttps://www.googleapis.com/auth/experimentsandconfigs\n```\n\n### Required Headers\n\n```http\nAuthorization: Bearer {access_token}\nContent-Type: application/json\nUser-Agent: antigravity/1.15.8 windows/amd64\nX-Goog-Api-Client: google-cloud-sdk vscode_cloudshelleditor/0.1\nClient-Metadata: {\"ideType\":\"ANTIGRAVITY\",\"platform\":\"MACOS\",\"pluginType\":\"GEMINI\"}\n```\n\nFor streaming requests, also include:\n```http\nAccept: text/event-stream\n```\n\n---\n\n## Available Models\n\n| Model Name | Model ID | Type | Status |\n|------------|----------|------|--------|\n| Claude Sonnet 4.6 | `claude-sonnet-4-6` | Anthropic | ✅ Verified |\n| Claude Opus 4.6 Thinking | `claude-opus-4-6-thinking` | Anthropic | ✅ Verified |\n| Gemini 3 Pro High | `gemini-3-pro-high` | Google | ✅ Verified |\n| Gemini 3 Pro Low | `gemini-3-pro-low` | Google | ✅ Verified |\n| GPT-OSS 120B Medium | `gpt-oss-120b-medium` | Other | ✅ Verified |\n\n---\n\n## Request Format\n\n### Basic Structure\n\n```json\n{\n  \"project\": \"{project_id}\",\n  \"model\": \"{model_id}\",\n  \"request\": {\n    \"contents\": [...],\n    \"generationConfig\": {...},\n    \"systemInstruction\": {...},\n    \"tools\": [...]\n  },\n  \"userAgent\": \"antigravity\",\n  \"requestId\": \"{unique_id}\"\n}\n```\n\n### Contents Array (REQUIRED)\n\n**⚠️ IMPORTANT: Must use Gemini-style format. Anthropic-style `messages` array is NOT supported.**\n\n```json\n{\n  \"contents\": [\n    {\n      \"role\": \"user\",\n      \"parts\": [\n        { \"text\": \"Your message here\" }\n      ]\n    },\n    {\n      \"role\": \"model\",\n      \"parts\": [\n        { \"text\": \"Assistant response\" }\n      ]\n    }\n  ]\n}\n```\n\n#### Role Values\n- `user` - Human/user messages\n- `model` - Assistant responses (NOT `assistant`)\n\n### Generation Config\n\n```json\n{\n  \"generationConfig\": {\n    \"maxOutputTokens\": 1000,\n    \"temperature\": 0.7,\n    \"topP\": 0.95,\n    \"topK\": 40,\n    \"stopSequences\": [\"STOP\"],\n    \"thinkingConfig\": {\n      \"thinkingBudget\": 8000,\n      \"includeThoughts\": true\n    }\n  }\n}\n```\n\n| Field | Type | Description |\n|-------|------|-------------|\n| `maxOutputTokens` | number | Maximum tokens in response |\n| `temperature` | number | Randomness (0.0 - 2.0) |\n| `topP` | number | Nucleus sampling threshold |\n| `topK` | number | Top-K sampling |\n| `stopSequences` | string[] | Stop generation triggers |\n| `thinkingConfig` | object | Extended thinking config |\n\n### System Instructions\n\n**⚠️ Must be an object with `parts`, NOT a plain string.**\n\n```json\n// ✅ CORRECT\n{\n  \"systemInstruction\": {\n    \"parts\": [\n      { \"text\": \"You are a helpful assistant.\" }\n    ]\n  }\n}\n\n// ❌ WRONG - Will return 400 error\n{\n  \"systemInstruction\": \"You are a helpful assistant.\"\n}\n```\n\n### Tools / Function Calling\n\n```json\n{\n  \"tools\": [\n    {\n      \"functionDeclarations\": [\n        {\n          \"name\": \"get_weather\",\n          \"description\": \"Get weather for a location\",\n          \"parameters\": {\n            \"type\": \"object\",\n            \"properties\": {\n              \"location\": {\n                \"type\": \"string\",\n                \"description\": \"City name\"\n              }\n            },\n            \"required\": [\"location\"]\n          }\n        }\n      ]\n    }\n  ]\n}\n```\n\n\n### Google Search Grounding\n\nGemini 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.\n\n#### How the `google_search` Tool Works\n\nThe model can call `google_search(query, urls?, thinking?)` which:\n1. Makes a **separate API call** to Antigravity with only `{ googleSearch: {} }` (no function declarations)\n2. Parses the `groundingMetadata` from the response\n3. Returns formatted markdown with sources and citations\n\n**Tool Parameters:**\n| Parameter | Type | Required | Description |\n|-----------|------|----------|-------------|\n| `query` | string | ✅ | The search query or question |\n| `urls` | string[] | ❌ | URLs to analyze (adds `urlContext` tool) |\n| `thinking` | boolean | ❌ | Enable deep thinking (default: true) |\n\n**Example Response:**\n```markdown\n## Search Results\n\nSpain won Euro 2024, defeating England 2-1 in the final...\n\n### Sources\n- [UEFA Euro 2024](https://uefa.com/...)\n- [Al Jazeera](https://aljazeera.com/...)\n\n### Search Queries Used\n- \"UEFA Euro 2024 winner\"\n```\n\n#### Raw API Format (for reference)\n\nThe underlying API uses these tool formats:\n\n**New API (Gemini 2.0+ / Gemini 3):**\n```json\n{\n  \"tools\": [\n    { \"googleSearch\": {} }\n  ]\n}\n```\n\n**Legacy API (Gemini 1.5 only - deprecated):**\n```json\n{\n  \"tools\": [\n    {\n      \"googleSearchRetrieval\": {\n        \"dynamicRetrievalConfig\": {\n          \"mode\": \"MODE_DYNAMIC\",\n          \"dynamicThreshold\": 0.3\n        }\n      }\n    }\n  ]\n}\n```\n\n**Response includes `groundingMetadata`:**\n```json\n{\n  \"groundingMetadata\": {\n    \"webSearchQueries\": [\"query1\", \"query2\"],\n    \"searchEntryPoint\": { \"renderedContent\": \"...\" },\n    \"groundingChunks\": [{ \"web\": { \"uri\": \"...\", \"title\": \"...\" } }],\n    \"groundingSupports\": [{ \"segment\": {...}, \"groundingChunkIndices\": [...] }]\n  }\n}\n```\n\n> **Important:** `googleSearch` and `urlContext` tools **cannot be combined with `functionDeclarations`** in the same request. This is why the plugin uses a separate API call.\n```\n\n### Function Name Rules\n\n| Rule | Description |\n|------|-------------|\n| First character | Must be a letter (a-z, A-Z) or underscore (_) |\n| Allowed characters | `a-zA-Z0-9`, underscores (`_`), dots (`.`), colons (`:`), dashes (`-`) |\n| Max length | 64 characters |\n| Not allowed | Slashes (`/`), spaces, other special characters |\n\n**Examples:**\n- ✅ `get_weather` - Valid\n- ✅ `mcp:mongodb.query` - Valid (colons and dots allowed)\n- ✅ `read-file` - Valid (dashes allowed)\n- ❌ `mcp/query` - Invalid (slashes not allowed)\n- ❌ `123_tool` - Invalid (must start with letter or underscore)\n\n### JSON Schema Support\n\n| Feature | Status | Notes |\n|---------|--------|-------|\n| `type` | ✅ Supported | `object`, `string`, `number`, `integer`, `boolean`, `array` |\n| `properties` | ✅ Supported | Object properties |\n| `required` | ✅ Supported | Required fields array |\n| `description` | ✅ Supported | Field descriptions |\n| `enum` | ✅ Supported | Enumerated values |\n| `items` | ✅ Supported | Array item schema |\n| `anyOf` | ✅ Supported | Converted to `any_of` internally |\n| `allOf` | ✅ Supported | Converted to `all_of` internally |\n| `oneOf` | ✅ Supported | Converted to `one_of` internally |\n| `additionalProperties` | ✅ Supported | Additional properties schema |\n| `const` | ❌ NOT Supported | Use `enum: [value]` instead |\n| `$ref` | ❌ NOT Supported | Inline the schema instead |\n| `$defs` / `definitions` | ❌ NOT Supported | Inline definitions instead |\n| `$schema` | ❌ NOT Supported | Strip from schema |\n| `$id` | ❌ NOT Supported | Strip from schema |\n| `default` | ❌ NOT Supported | Strip from schema |\n| `examples` | ❌ NOT Supported | Strip from schema |\n| `title` (nested) | ⚠️ Caution | May cause issues in nested objects |\n\n**⚠️ IMPORTANT:** The following features will cause a 400 error if sent to the API:\n- `const` - Convert to `enum: [value]` instead\n- `$ref` / `$defs` - Inline the schema definitions\n- `$schema` / `$id` - Strip these metadata fields\n- `default` / `examples` - Strip these documentation fields\n\n```json\n// ❌ WRONG - Will return 400 error\n{ \"type\": { \"const\": \"email\" } }\n\n// ✅ CORRECT - Use enum instead\n{ \"type\": { \"enum\": [\"email\"] } }\n```\n\n**Note:** The plugin automatically handles these conversions via the `schema-transform.ts` module.\n\n---\n\n## Response Format\n\n### Non-Streaming Response\n\n```json\n{\n  \"response\": {\n    \"candidates\": [\n      {\n        \"content\": {\n          \"role\": \"model\",\n          \"parts\": [\n            { \"text\": \"Response text here\" }\n          ]\n        },\n        \"finishReason\": \"STOP\"\n      }\n    ],\n    \"usageMetadata\": {\n      \"promptTokenCount\": 16,\n      \"candidatesTokenCount\": 4,\n      \"totalTokenCount\": 20\n    },\n    \"modelVersion\": \"claude-sonnet-4-6\",\n    \"responseId\": \"msg_vrtx_...\"\n  },\n  \"traceId\": \"abc123...\"\n}\n```\n\n### Streaming Response (SSE)\n\nContent-Type: `text/event-stream`\n\n```\ndata: {\"response\": {\"candidates\": [{\"content\": {\"role\": \"model\", \"parts\": [{\"text\": \"Hello\"}]}}], \"usageMetadata\": {...}, \"modelVersion\": \"...\", \"responseId\": \"...\"}, \"traceId\": \"...\"}\n\ndata: {\"response\": {\"candidates\": [{\"content\": {\"role\": \"model\", \"parts\": [{\"text\": \" world\"}]}, \"finishReason\": \"STOP\"}], \"usageMetadata\": {...}}, \"traceId\": \"...\"}\n\n```\n\n### Response Fields\n\n| Field | Description |\n|-------|-------------|\n| `response.candidates` | Array of response candidates |\n| `response.candidates[].content.role` | Always `\"model\"` |\n| `response.candidates[].content.parts` | Array of content parts |\n| `response.candidates[].finishReason` | `STOP`, `MAX_TOKENS`, `OTHER` |\n| `response.usageMetadata.promptTokenCount` | Input tokens |\n| `response.usageMetadata.candidatesTokenCount` | Output tokens |\n| `response.usageMetadata.totalTokenCount` | Total tokens |\n| `response.usageMetadata.thoughtsTokenCount` | Thinking tokens (Gemini) |\n| `response.modelVersion` | Actual model used |\n| `response.responseId` | Request ID (format varies by model) |\n| `traceId` | Trace ID for debugging |\n\n### Response ID Formats\n\n| Model Type | Format | Example |\n|------------|--------|---------|\n| Claude | `msg_vrtx_...` | `msg_vrtx_01UDKZG8PWPj9mjajje8d7u7` |\n| Gemini | Base64-like | `ypM9abPqFKWl0-kPvamgqQw` |\n| GPT-OSS | Base64-like | `y5M9aZaSKq6z2roPoJ7pEA` |\n\n---\n\n## Function Call Response\n\nWhen the model wants to call a function:\n\n```json\n{\n  \"response\": {\n    \"candidates\": [\n      {\n        \"content\": {\n          \"role\": \"model\",\n          \"parts\": [\n            {\n              \"functionCall\": {\n                \"name\": \"get_weather\",\n                \"args\": {\n                  \"location\": \"Paris\"\n                },\n                \"id\": \"toolu_vrtx_01PDbPTJgBJ3AJ8BCnSXvUqk\"\n              }\n            }\n          ]\n        },\n        \"finishReason\": \"OTHER\"\n      }\n    ]\n  }\n}\n```\n\n### Providing Function Results\n\n```json\n{\n  \"contents\": [\n    { \"role\": \"user\", \"parts\": [{ \"text\": \"What's the weather?\" }] },\n    { \"role\": \"model\", \"parts\": [{ \"functionCall\": { \"name\": \"get_weather\", \"args\": {...}, \"id\": \"...\" } }] },\n    { \"role\": \"user\", \"parts\": [{ \"functionResponse\": { \"name\": \"get_weather\", \"id\": \"...\", \"response\": { \"temperature\": \"22C\" } } }] }\n  ]\n}\n```\n\n---\n\n## Thinking / Extended Reasoning\n\n### Thinking Config\n\nFor thinking-capable models (`*-thinking`), use:\n\n```json\n{\n  \"generationConfig\": {\n    \"maxOutputTokens\": 10000,\n    \"thinkingConfig\": {\n      \"thinkingBudget\": 8000,\n      \"includeThoughts\": true\n    }\n  }\n}\n```\n\n**⚠️ IMPORTANT: `maxOutputTokens` must be GREATER than `thinkingBudget`**\n\n### Thinking Response (Gemini)\n\nGemini models return thinking with signatures:\n\n```json\n{\n  \"parts\": [\n    {\n      \"thoughtSignature\": \"ErADCq0DAXLI2nx...\",\n      \"text\": \"Let me think about this...\"\n    },\n    {\n      \"text\": \"The answer is...\"\n    }\n  ]\n}\n```\n\n### Thinking Response (Claude)\n\nClaude thinking models may include `thought: true` parts:\n\n```json\n{\n  \"parts\": [\n    {\n      \"thought\": true,\n      \"text\": \"Reasoning process...\",\n      \"thoughtSignature\": \"...\"\n    },\n    {\n      \"text\": \"Final answer...\"\n    }\n  ]\n}\n```\n\n---\n\n## Error Responses\n\n### Error Structure\n\n```json\n{\n  \"error\": {\n    \"code\": 400,\n    \"message\": \"Error description\",\n    \"status\": \"INVALID_ARGUMENT\",\n    \"details\": [...]\n  }\n}\n```\n\n### Common Error Codes\n\n| Code | Status | Description |\n|------|--------|-------------|\n| 400 | `INVALID_ARGUMENT` | Invalid request format |\n| 401 | `UNAUTHENTICATED` | Invalid/expired token |\n| 403 | `PERMISSION_DENIED` | No access to resource |\n| 404 | `NOT_FOUND` | Model not found |\n| 429 | `RESOURCE_EXHAUSTED` | Rate limit exceeded |\n\n### Rate Limit Response\n\n```json\n{\n  \"error\": {\n    \"code\": 429,\n    \"message\": \"You have exhausted your capacity on this model. Your quota will reset after 3s.\",\n    \"status\": \"RESOURCE_EXHAUSTED\",\n    \"details\": [\n      {\n        \"@type\": \"type.googleapis.com/google.rpc.RetryInfo\",\n        \"retryDelay\": \"3.957525076s\"\n      }\n    ]\n  }\n}\n```\n\n---\n\n## NOT Supported\n\nThe following Anthropic/Vertex AI features are **NOT supported**:\n\n| Feature | Error |\n|---------|-------|\n| `anthropic_version` | Unknown field |\n| `messages` array | Unknown field |\n| `max_tokens` | Unknown field |\n| Plain string `systemInstruction` | Invalid value |\n| `system_instruction` (snake_case at root) | Unknown field |\n| JSON Schema `const` | Unknown field (use `enum: [value]`) |\n| JSON Schema `$ref` | Not supported (inline instead) |\n| JSON Schema `$defs` | Not supported (inline instead) |\n| Tool names with `/` | Invalid (use `_` or `:` instead) |\n| Tool names starting with digit | Invalid (must start with letter/underscore) |\n\n---\n\n## Complete Request Example\n\n```json\n{\n  \"project\": \"my-project-id\",\n  \"model\": \"claude-sonnet-4-6\",\n  \"request\": {\n    \"contents\": [\n      {\n        \"role\": \"user\",\n        \"parts\": [\n          { \"text\": \"Hello, how are you?\" }\n        ]\n      }\n    ],\n    \"systemInstruction\": {\n      \"parts\": [\n        { \"text\": \"You are a helpful assistant.\" }\n      ]\n    },\n    \"generationConfig\": {\n      \"maxOutputTokens\": 1000,\n      \"temperature\": 0.7\n    }\n  },\n  \"userAgent\": \"antigravity\",\n  \"requestId\": \"agent-abc123\"\n}\n```\n\n---\n\n## Response Headers\n\n| Header | Description |\n|--------|-------------|\n| `x-cloudaicompanion-trace-id` | Trace ID for debugging |\n| `server-timing` | Request duration |\n\n---\n\n## Comparison: Antigravity vs Vertex AI Anthropic\n\n| Feature | Antigravity | Vertex AI Anthropic |\n|---------|-------------|---------------------|\n| Endpoint | `cloudcode-pa.googleapis.com` | `aiplatform.googleapis.com` |\n| Request format | Gemini-style `contents` | Anthropic `messages` |\n| `anthropic_version` | Not used | Required |\n| Model names | Simple (`claude-sonnet-4-6`) | Versioned (`claude-4-5@date`) |\n| Response format | `candidates[]` | Anthropic `content[]` |\n| Multi-model support | Yes (Claude, Gemini, etc.) | Anthropic only |\n\n---\n\n## Changelog\n\n- **2025-12-14**: Added function calling quirks, JSON Schema support matrix, tool name rules\n- **2025-12-13**: Initial specification based on direct API testing\n"
  },
  {
    "path": "docs/ARCHITECTURE.md",
    "content": "# Architecture Guide\n\n**Last Updated:** December 2025\n\nThis document explains how the Antigravity plugin works, including the request/response flow, Claude-specific handling, and session recovery.\n\n---\n\n## Overview\n\n```\n┌─────────────────────────────────────────────────────────────────┐\n│  OpenCode ──▶ Plugin ──▶ Antigravity API ──▶ Claude/Gemini      │\n│     │           │              │                   │            │\n│     │           │              │                   └─ Model     │\n│     │           │              └─ Google's gateway (Gemini fmt) │\n│     │           └─ THIS PLUGIN (auth, transform, recovery)      │\n│     └─ AI coding assistant                                      │\n└─────────────────────────────────────────────────────────────────┘\n```\n\nThe plugin intercepts requests to `generativelanguage.googleapis.com`, transforms them for the Antigravity API, and handles authentication, rate limits, and error recovery.\n\n---\n\n## Module Structure\n\n```\nsrc/\n├── index.ts                 # Plugin exports\n├── plugin.ts                # Main entry, fetch interceptor\n├── constants.ts             # Endpoints, headers, config\n├── antigravity/\n│   └── oauth.ts             # OAuth token exchange\n└── plugin/\n    ├── auth.ts              # Token validation & refresh\n    ├── request.ts           # Request transformation (main logic)\n    ├── request-helpers.ts   # Schema cleaning, thinking filters\n    ├── thinking-recovery.ts # Turn boundary detection, crash recovery\n    ├── recovery.ts          # Session recovery (tool_result_missing)\n    ├── quota.ts             # Quota checking (API usage stats)\n    ├── cache.ts             # Auth & signature caching\n    ├── cache/\n    │   └── signature-cache.ts # Disk-based signature persistence\n    ├── config/\n    │   ├── schema.ts        # Zod config schema\n    │   └── loader.ts        # Config file loading\n    ├── accounts.ts          # Multi-account management\n    ├── server.ts            # OAuth callback server\n    └── debug.ts             # Debug logging\n```\n\n---\n\n## Request Flow\n\n### 1. Interception (`plugin.ts`)\n\n```typescript\nfetch() intercepted → isGenerativeLanguageRequest() → prepareAntigravityRequest()\n```\n\n- Account selection (round-robin, rate-limit aware)\n- Token refresh if expired\n- Endpoint fallback (daily → autopush → prod)\n\n### 2. Request Transformation (`request.ts`)\n\n| Step | What Happens |\n|------|--------------|\n| Model detection | Detect Claude/Gemini from URL |\n| Thinking config | Add `thinkingConfig` for thinking models |\n| Thinking strip | Remove ALL thinking blocks (Claude) |\n| Tool normalization | Convert to `functionDeclarations[]` |\n| Schema cleaning | Remove unsupported JSON Schema fields |\n| ID assignment | Assign IDs to tool calls (FIFO matching) |\n| Wrap request | `{ project, model, request: {...} }` |\n\n### 3. Response Transformation (`request.ts`)\n\n| Step | What Happens |\n|------|--------------|\n| SSE streaming | Real-time line-by-line TransformStream |\n| Signature caching | Cache `thoughtSignature` for display |\n| Format transform | `thought: true` → `type: \"reasoning\"` |\n| Envelope unwrap | Extract inner `response` object |\n\n---\n\n## Claude-Specific Handling\n\n### Why Special Handling?\n\nClaude through Antigravity requires:\n1. **Gemini format** - `contents[].parts[]` not `messages[].content[]`\n2. **Thinking signatures** - Multi-turn needs signed blocks or errors\n3. **Schema restrictions** - Rejects `const`, `$ref`, `$defs`, etc.\n4. **Tool validation** - `VALIDATED` mode requires proper schemas\n\n### Thinking Block Strategy (v2.0)\n\n**Problem:** OpenCode stores thinking blocks, but may corrupt signatures.\n\n**Solution:** Strip ALL thinking blocks from outgoing requests.\n\n```\nTurn 1 Response: { thought: true, text: \"...\", thoughtSignature: \"abc\" }\n                 ↓ (stored by OpenCode, possibly corrupted)\nTurn 2 Request:  Plugin STRIPS all thinking blocks\n                 ↓\nClaude API:      Generates fresh thinking\n```\n\n**Why this works:**\n- Zero signature errors (impossible to have invalid signatures)\n- Same quality (Claude sees full conversation, re-thinks fresh)\n- Simpler code (no complex validation/restoration)\n\n### Thinking Injection for Tool Use\n\nClaude API requires thinking before `tool_use` blocks. The plugin:\n\n1. Caches signed thinking from responses (`lastSignedThinkingBySessionKey`)\n2. On subsequent requests, injects cached thinking before tool_use\n3. Only injects for the **first** assistant message of a turn (not every message)\n\n**Turn boundary detection** (`thinking-recovery.ts`):\n```typescript\n// A \"turn\" starts after a real user message (not tool_result)\n// Only inject thinking into first assistant message after that\n```\n\n---\n\n## Session Recovery\n\n### Tool Result Missing Error\n\nWhen a tool execution is interrupted (ESC, timeout, crash):\n\n```\nError: tool_use ids were found without tool_result blocks immediately after\n```\n\n**Recovery flow** (`recovery.ts`):\n\n1. Detect error via `session.error` event\n2. Fetch session messages via `client.session.messages()`\n3. Extract `tool_use` IDs from failed message\n4. Inject synthetic `tool_result` blocks:\n   ```typescript\n   { type: \"tool_result\", tool_use_id: id, content: \"Operation cancelled\" }\n   ```\n5. Send via `client.session.prompt()`\n6. Optionally auto-resume with \"continue\"\n\n### Thinking Block Order Error\n\n```\nError: Expected thinking but found text\n```\n\n**Recovery** (`thinking-recovery.ts`):\n\n1. Detect conversation is in tool loop without thinking at turn start\n2. Close the corrupted turn with synthetic messages\n3. Start fresh turn where Claude can generate new thinking\n\n---\n\n## Schema Cleaning\n\nClaude rejects unsupported JSON Schema features. The plugin uses an **allowlist approach**:\n\n**Kept:** `type`, `properties`, `required`, `description`, `enum`, `items`\n\n**Removed:** `const`, `$ref`, `$defs`, `default`, `examples`, `additionalProperties`, `$schema`, `title`\n\n**Transformations:**\n- `const: \"value\"` → `enum: [\"value\"]`\n- Empty object schema → Add placeholder `reason` property\n\n---\n\n## Multi-Account Load Balancing\n\n### How It Works\n\n1. **Sticky selection** - Same account until rate limited (preserves cache)\n2. **Per-model-family** - Claude/Gemini rate limits tracked separately\n3. **Dual quota (Gemini)** - Antigravity + Gemini CLI headers\n4. **Automatic failover** - On 429, switch to next available account\n\n### Account Storage\n\nLocation: `~/.config/opencode/antigravity-accounts.json`\n\nContains OAuth refresh tokens - treat as sensitive.\n\n---\n\n## Configuration\n\n### Environment Variables\n\n| Variable | Purpose |\n|----------|---------|\n| `OPENCODE_ANTIGRAVITY_DEBUG` | `1` or `2` for file debug logging |\n| `OPENCODE_ANTIGRAVITY_DEBUG_TUI` | `1` or `true` for TUI log panel debug output |\n| `OPENCODE_ANTIGRAVITY_QUIET` | Suppress toast notifications |\n\n`debug` and `debug_tui` are independent sinks: `debug` controls file logs, while `debug_tui` controls TUI logs.\n\n### Config File\n\nLocation: `~/.config/opencode/antigravity.json`\n\n```json\n{\n  \"session_recovery\": true,\n  \"auto_resume\": true,\n  \"resume_text\": \"continue\",\n  \"keep_thinking\": false\n}\n```\n\n---\n\n## Key Functions Reference\n\n### `request.ts`\n\n| Function | Purpose |\n|----------|---------|\n| `prepareAntigravityRequest()` | Main request transformation |\n| `transformAntigravityResponse()` | SSE streaming, format conversion |\n| `ensureThinkingBeforeToolUseInContents()` | Inject cached thinking |\n| `createStreamingTransformer()` | Real-time SSE processing |\n\n### `request-helpers.ts`\n\n| Function | Purpose |\n|----------|---------|\n| `deepFilterThinkingBlocks()` | Recursive thinking block removal |\n| `cleanJSONSchemaForAntigravity()` | Schema sanitization |\n| `transformThinkingParts()` | `thought` → `reasoning` format |\n\n### `thinking-recovery.ts`\n\n| Function | Purpose |\n|----------|---------|\n| `analyzeConversationState()` | Detect turn boundaries, tool loops |\n| `needsThinkingRecovery()` | Check if recovery needed |\n| `closeToolLoopForThinking()` | Inject synthetic messages |\n\n### `recovery.ts`\n\n| Function | Purpose |\n|----------|---------|\n| `handleSessionRecovery()` | Main recovery orchestration |\n| `createSessionRecoveryHook()` | Hook factory for plugin |\n\n---\n\n## Debugging\n\n### Enable Logging\n\n```bash\nexport OPENCODE_ANTIGRAVITY_DEBUG=2      # Verbose file logs\nexport OPENCODE_ANTIGRAVITY_DEBUG_TUI=1  # TUI log panel output\n```\n\n### Log Location\n\n`~/.config/opencode/antigravity-logs/`\n\n### What To Check\n\n1. Is `isClaudeModel` true for Claude models?\n2. Are thinking blocks being stripped?\n3. Are tool schemas being cleaned?\n4. Is session recovery triggering?\n\n---\n\n## Troubleshooting\n\n| Error | Cause | Solution |\n|-------|-------|----------|\n| `invalid signature` | Corrupted thinking block | Update plugin (strips all thinking) |\n| `Unknown field: const` | Schema uses `const` | Plugin auto-converts to `enum` |\n| `tool_use without tool_result` | Interrupted execution | Session recovery injects results |\n| `Expected thinking but found text` | Turn started without thinking | Thinking recovery closes turn |\n| `429 Too Many Requests` | Rate limited | Plugin auto-rotates accounts |\n\n---\n\n## See Also\n\n- [ANTIGRAVITY_API_SPEC.md](./ANTIGRAVITY_API_SPEC.md) - API reference\n- [README.md](../README.md) - Installation & usage\n"
  },
  {
    "path": "docs/CONFIGURATION.md",
    "content": "# Configuration\n\nCreate `~/.config/opencode/antigravity.json` (or `.opencode/antigravity.json` in project root):\n\n```json\n{\n  \"$schema\": \"https://raw.githubusercontent.com/NoeFabris/opencode-antigravity-auth/main/assets/antigravity.schema.json\"\n}\n```\n\nMost settings have sensible defaults. Only configure what you need.\n\n---\n\n## Quick Start\n\n**Minimal config (recommended for most users):**\n\n```json\n{\n  \"$schema\": \"https://raw.githubusercontent.com/NoeFabris/opencode-antigravity-auth/main/assets/antigravity.schema.json\"\n}\n```\n\n**With web search enabled:**\n\nThe plugin provides a `google_search` tool that the model can call to search the web. No configuration is needed - the tool is always available.\n\n---\n\n## Model Behavior\n\nSettings that affect how the model thinks and responds.\n\n| Option | Default | Description |\n|--------|---------|-------------|\n| `keep_thinking` | `false` | Preserve Claude's thinking blocks across turns. **Warning:** enabling may degrade model stability. |\n| `session_recovery` | `true` | Auto-recover from tool_result_missing errors |\n| `auto_resume` | `false` | Auto-send resume prompt after recovery |\n| `resume_text` | `\"continue\"` | Text to send when auto-resuming |\n\n> **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.\n\n### About `keep_thinking`\n\nWhen `true`, Claude's thinking blocks are preserved in conversation history:\n- **Pros:** Model remembers its reasoning, more coherent across turns\n- **Cons:** May degrade model stability, slightly larger context\n\nWhen `false` (default), thinking is stripped:\n- **Pros:** More stable model behavior, smaller context\n- **Cons:** Model may be less coherent, forgets previous reasoning\n\n---\n\n## Account Rotation\n\nSettings for managing multiple Google accounts.\n\n| Option | Default | Description |\n|--------|---------|-------------|\n| `account_selection_strategy` | `\"hybrid\"` | How to select accounts |\n| `switch_on_first_rate_limit` | `true` | Switch account immediately on first 429 |\n| `pid_offset_enabled` | `false` | Distribute sessions across accounts (for parallel agents) |\n| `quota_fallback` | `false` | Deprecated (ignored). Kept for backward compatibility; Gemini fallback is automatic |\n\n### Strategy Guide\n\n| Your Setup | Recommended Strategy | Why |\n|------------|---------------------|-----|\n| **1 account** | `\"sticky\"` | No rotation needed, preserve prompt cache |\n| **2-3 accounts** | `\"hybrid\"` (default) | Smart rotation with health scoring |\n| **4+ accounts** | `\"round-robin\"` | Maximum throughput |\n| **Parallel agents** | `\"round-robin\"` + `pid_offset_enabled: true` | Distribute across accounts |\n\n### Available Strategies\n\n| Strategy | Behavior | Best For |\n|----------|----------|----------|\n| `sticky` | Same account until rate-limited | Single account, prompt cache |\n| `round-robin` | Rotate on every request | Maximum throughput |\n| `hybrid` | Health score + token bucket + LRU | Smart distribution (default) |\n\n---\n\n## App Behavior\n\nSettings for plugin behavior.\n\n| Option | Default | Description |\n|--------|---------|-------------|\n| `quiet_mode` | `false` | Hide toast notifications (except recovery) |\n| `debug` | `false` | Enable debug logging |\n| `log_dir` | OS default | Custom directory for debug logs |\n| `auto_update` | `true` | Enable automatic plugin updates |\n\n### Debug Logging\n\n```json\n{\n  \"debug\": true,\n  \"debug_tui\": true\n}\n```\n\nLogs are written to `~/.config/opencode/antigravity-logs/` (or `log_dir` if set).\n\n---\n\n## Recommended Configs\n\nCopy-paste ready configs with all recommended settings enabled.\n\n### 1 Account\n\n```json\n{\n  \"$schema\": \"https://raw.githubusercontent.com/NoeFabris/opencode-antigravity-auth/main/assets/antigravity.schema.json\",\n  \"account_selection_strategy\": \"sticky\"\n}\n```\n\n**Why these settings:**\n- `sticky` — No rotation needed, preserves Anthropic prompt cache\n\n### 2-3 Accounts\n\n```json\n{\n  \"$schema\": \"https://raw.githubusercontent.com/NoeFabris/opencode-antigravity-auth/main/assets/antigravity.schema.json\",\n  \"account_selection_strategy\": \"hybrid\"\n}\n```\n\n**Why these settings:**\n- `hybrid` — Smart rotation using health scores, avoids bad accounts\n\n### 3+ Accounts (Power Users / Parallel Agents)\n\n```json\n{\n  \"$schema\": \"https://raw.githubusercontent.com/NoeFabris/opencode-antigravity-auth/main/assets/antigravity.schema.json\",\n  \"account_selection_strategy\": \"round-robin\",\n  \"switch_on_first_rate_limit\": true,\n  \"pid_offset_enabled\": true\n}\n```\n\n**Why these settings:**\n- `round-robin` — Maximum throughput, rotates every request\n- `switch_on_first_rate_limit` — Immediately switch on 429 (default: true)\n- `pid_offset_enabled` — Different sessions use different starting accounts\n\n---\n\n## What's Enabled by Default\n\nThese settings are already `true` by default — you don't need to set them:\n\n| Setting | Default | What it does |\n|---------|---------|--------------|\n| `session_recovery` | `true` | Auto-recover from errors |\n| `auto_update` | `true` | Keep plugin updated |\n| `switch_on_first_rate_limit` | `true` | Fast account switching |\n\nThese settings are `false` by default:\n\n| Setting | Default | What it does |\n|---------|---------|--------------|\n| `keep_thinking` | `false` | Preserve Claude thinking (may degrade stability) |\n| `auto_resume` | `false` | Auto-continue after recovery |\n\n---\n\n## Advanced Settings\n\n> These settings are for edge cases. Most users don't need to change them.\n\n<details>\n<summary><b>Error Recovery (internal)</b></summary>\n\n| Option | Default | Description |\n|--------|---------|-------------|\n| `empty_response_max_attempts` | `4` | Retries for empty API responses |\n| `empty_response_retry_delay_ms` | `2000` | Delay between retries |\n| `tool_id_recovery` | `true` | Fix mismatched tool IDs from context compaction |\n| `claude_tool_hardening` | `true` | Prevent tool parameter hallucination |\n| `max_rate_limit_wait_seconds` | `300` | Max wait time when rate limited (0=unlimited) |\n\n</details>\n\n<details>\n<summary><b>Token Management (internal)</b></summary>\n\n| Option | Default | Description |\n|--------|---------|-------------|\n| `proactive_token_refresh` | `true` | Refresh tokens before expiry |\n| `proactive_refresh_buffer_seconds` | `1800` | Refresh 30 min before expiry |\n| `proactive_refresh_check_interval_seconds` | `300` | Check interval |\n\n</details>\n\n<details>\n<summary><b>Signature Cache (internal)</b></summary>\n\nUsed when `keep_thinking: true`. Most users don't need to configure this.\n\n| Option | Default | Description |\n|--------|---------|-------------|\n| `signature_cache.enabled` | `true` | Enable disk caching |\n| `signature_cache.memory_ttl_seconds` | `3600` | In-memory cache TTL (1 hour) |\n| `signature_cache.disk_ttl_seconds` | `172800` | Disk cache TTL (48 hours) |\n| `signature_cache.write_interval_seconds` | `60` | Background write interval |\n\n</details>\n\n<details>\n<summary><b>Health Score Tuning (internal)</b></summary>\n\nUsed by `hybrid` strategy. Most users don't need to configure this.\n\n| Option | Default | Description |\n|--------|---------|-------------|\n| `health_score.initial` | `70` | Starting health score |\n| `health_score.success_reward` | `1` | Points added on success |\n| `health_score.rate_limit_penalty` | `-10` | Points removed on rate limit |\n| `health_score.failure_penalty` | `-20` | Points removed on failure |\n| `health_score.recovery_rate_per_hour` | `2` | Points recovered per hour |\n| `health_score.min_usable` | `50` | Minimum score to use account |\n| `health_score.max_score` | `100` | Maximum health score |\n\n</details>\n\n<details>\n<summary><b>Token Bucket Tuning (internal)</b></summary>\n\nUsed by `hybrid` strategy. Most users don't need to configure this.\n\n| Option | Default | Description |\n|--------|---------|-------------|\n| `token_bucket.max_tokens` | `50` | Maximum tokens in bucket |\n| `token_bucket.regeneration_rate_per_minute` | `6` | Tokens regenerated per minute |\n| `token_bucket.initial_tokens` | `50` | Starting tokens |\n\n</details>\n"
  },
  {
    "path": "docs/MODEL-VARIANTS.md",
    "content": "# Model Variants\n\nOpenCode's variant system allows you to configure thinking budget dynamically instead of defining separate models for each thinking level.\n\n---\n\n## How Variants Work\n\nWhen 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.\n\n```bash\nopencode run \"Hello\" --model=google/antigravity-claude-opus-4-6-thinking --variant=max\n```\n\n---\n\n## Variant Configuration\n\nDefine variants in your model configuration:\n\n```json\n{\n  \"antigravity-claude-opus-4-6-thinking\": {\n    \"name\": \"Claude Opus 4.6 Thinking\",\n    \"limit\": { \"context\": 200000, \"output\": 64000 },\n    \"modalities\": { \"input\": [\"text\", \"image\", \"pdf\"], \"output\": [\"text\"] },\n    \"variants\": {\n      \"low\": { \"thinkingConfig\": { \"thinkingBudget\": 8192 } },\n      \"max\": { \"thinkingConfig\": { \"thinkingBudget\": 32768 } }\n    }\n  }\n}\n```\n\n---\n\n## Supported Variant Formats\n\nThe plugin accepts different variant formats depending on the model family:\n\n| Model Family | Variant Format | Example |\n|--------------|----------------|---------|\n| **Claude** | `thinkingConfig.thinkingBudget` | `{ \"thinkingConfig\": { \"thinkingBudget\": 8192 } }` |\n| **Gemini 3** | `thinkingLevel` | `{ \"thinkingLevel\": \"high\" }` |\n| **Gemini 2.5** | `thinkingConfig.thinkingBudget` | `{ \"thinkingConfig\": { \"thinkingBudget\": 8192 } }` |\n\n---\n\n## Gemini 3 Thinking Levels\n\nGemini 3 models use string-based thinking levels. Available levels differ by model:\n\n| Level | Flash | Pro | Description |\n|-------|-------|-----|-------------|\n| `minimal` | ✅ | ❌ | Minimal thinking, lowest latency |\n| `low` | ✅ | ✅ | Light thinking |\n| `medium` | ✅ | ❌ | Balanced thinking |\n| `high` | ✅ | ✅ | Maximum thinking (default) |\n\n> **Note:** The API rejects invalid levels (e.g., `\"minimal\"` on Pro). Configure variants accordingly.\n\n### Gemini 3 Pro Example\n\n```json\n{\n  \"antigravity-gemini-3-pro\": {\n    \"name\": \"Gemini 3 Pro (Antigravity)\",\n    \"limit\": { \"context\": 1048576, \"output\": 65535 },\n    \"modalities\": { \"input\": [\"text\", \"image\", \"pdf\"], \"output\": [\"text\"] },\n    \"variants\": {\n      \"low\": { \"thinkingLevel\": \"low\" },\n      \"high\": { \"thinkingLevel\": \"high\" }\n    }\n  }\n}\n```\n\n### Gemini 3 Flash Example\n\n```json\n{\n  \"antigravity-gemini-3-flash\": {\n    \"name\": \"Gemini 3 Flash (Antigravity)\",\n    \"limit\": { \"context\": 1048576, \"output\": 65536 },\n    \"modalities\": { \"input\": [\"text\", \"image\", \"pdf\"], \"output\": [\"text\"] },\n    \"variants\": {\n      \"minimal\": { \"thinkingLevel\": \"minimal\" },\n      \"low\": { \"thinkingLevel\": \"low\" },\n      \"medium\": { \"thinkingLevel\": \"medium\" },\n      \"high\": { \"thinkingLevel\": \"high\" }\n    }\n  }\n}\n```\n\n---\n\n## Claude Thinking Budget\n\nClaude models use token-based thinking budgets:\n\n| Variant | Budget | Description |\n|---------|--------|-------------|\n| `low` | 8192 | Light thinking |\n| `max` | 32768 | Maximum thinking |\n\n### Claude Example\n\n```json\n{\n  \"antigravity-claude-opus-4-6-thinking\": {\n    \"name\": \"Claude Opus 4.6 Thinking (Antigravity)\",\n    \"limit\": { \"context\": 200000, \"output\": 64000 },\n    \"modalities\": { \"input\": [\"text\", \"image\", \"pdf\"], \"output\": [\"text\"] },\n    \"variants\": {\n      \"low\": { \"thinkingConfig\": { \"thinkingBudget\": 8192 } },\n      \"max\": { \"thinkingConfig\": { \"thinkingBudget\": 32768 } }\n    }\n  }\n}\n```\n\nYou can define custom budgets:\n\n```json\n{\n  \"variants\": {\n    \"minimal\": { \"thinkingConfig\": { \"thinkingBudget\": 4096 } },\n    \"low\": { \"thinkingConfig\": { \"thinkingBudget\": 8192 } },\n    \"medium\": { \"thinkingConfig\": { \"thinkingBudget\": 16384 } },\n    \"high\": { \"thinkingConfig\": { \"thinkingBudget\": 24576 } },\n    \"max\": { \"thinkingConfig\": { \"thinkingBudget\": 32768 } }\n  }\n}\n```\n\n---\n\n## Legacy Budget Format (Deprecated)\n\nFor Gemini 3 models, the old `thinkingBudget` format is still supported but deprecated:\n\n| Budget Range | Maps to Level |\n|--------------|---------------|\n| ≤ 8192 | low |\n| ≤ 16384 | medium |\n| > 16384 | high |\n\n**Recommended:** Use `thinkingLevel` directly for Gemini 3 models.\n\n---\n\n## Tier-Suffixed Names\n\nTier-suffixed model names are still accepted:\n\n- `antigravity-claude-opus-4-6-thinking-low`\n- `antigravity-claude-opus-4-6-thinking-medium`\n- `antigravity-claude-opus-4-6-thinking-high`\n- `antigravity-gemini-3-pro-low`\n- `antigravity-gemini-3-pro-high`\n- `gemini-3-pro-low`\n- `gemini-3-flash-medium`\n\nHowever, **we recommend using simplified model names with variants** for:\n\n- **Cleaner model picker** — 7 models instead of 12+\n- **Simpler config** — No need to configure both `antigravity-` and `-preview` versions\n- **Automatic quota routing** — Plugin handles model name transformation\n- **Flexible budgets** — Define any budget, not just preset tiers\n- **Future-proof** — Works with OpenCode's native variant system\n\n---\n\n## Benefits of Variants\n\n| Before (tier-suffixed) | After (variants) |\n|------------------------|------------------|\n| 12+ separate models | 4 models with variants |\n| Fixed thinking budgets | Customizable budgets |\n| Cluttered model picker | Clean model picker |\n| Hard to add new tiers | Easy to add new variants |\n"
  },
  {
    "path": "docs/MULTI-ACCOUNT.md",
    "content": "# Multi-Account Setup\n\nAdd multiple Google accounts to increase your combined quota. The plugin automatically rotates between accounts when one is rate-limited.\n\n```bash\nopencode auth login  # Run again to add more accounts\n```\n\n---\n\n## Load Balancing Behavior\n\n- **Sticky account selection** — Sticks to the same account until rate-limited (preserves Anthropic's prompt cache)\n- **Per-model-family limits** — Rate limits tracked separately for Claude and Gemini models\n- **Antigravity-first for Gemini** — All Gemini requests use Antigravity quota first, then automatically fall back to Gemini CLI when exhausted across all accounts\n- **Smart retry threshold** — Short rate limits (≤5s) are retried on same account\n- **Exponential backoff** — Increasing delays for consecutive rate limits\n\n---\n\n## Dual Quota Pools\n\nFor Gemini models, the plugin accesses **two independent quota pools** per account:\n\n| Quota Pool | When Used |\n|------------|-----------|\n| **Antigravity** | Default for all requests |\n| **Gemini CLI** | Automatic fallback between Antigravity and Gemini CLI in both directions |\n\nThis effectively **doubles your Gemini quota** through automatic fallback between Antigravity and Gemini CLI pools.\n\n### How Quota Fallback Works\n\n1. Request uses Antigravity quota on current account\n2. If rate-limited, plugin checks if ANY other account has Antigravity available\n3. If yes → switch to that account (stay on Antigravity)\n4. If no (all accounts exhausted) → fall back to Gemini CLI quota on current account\n5. Model names are automatically transformed (e.g., `gemini-3-flash` → `gemini-3-flash-preview`)\n\nAutomatic fallback between pools is always enabled for Gemini requests.\n\n---\n\n## Checking Quotas\n\nCheck your current API usage across all accounts:\n\n```bash\nopencode auth login\n# Select \"Check quotas\" from the menu\n```\n\nThis shows remaining quota percentages and reset times for each model family:\n- **Claude** - Claude Opus/Sonnet quota\n- **Gemini 3 Pro** - Gemini 3 Pro quota\n- **Gemini 3 Flash** - Gemini 3 Flash quota\n\n### Standalone Quota Script\n\nFor checking quotas outside OpenCode (debugging, CI, etc.):\n\n```bash\nnode scripts/check-quota.mjs                    # Check all accounts\nnode scripts/check-quota.mjs --account 2        # Check specific account\nnode scripts/check-quota.mjs --path /path/to/accounts.json  # Custom path\n```\n\n---\n\n## Managing Accounts\n\nEnable or disable specific accounts to control which ones are used for requests:\n\n```bash\nopencode auth login\n# Select \"Manage accounts (enable/disable)\"\n```\n\nOr select an account from the list and choose \"Enable/Disable account\".\n\n**Disabled accounts:**\n- Are excluded from automatic rotation\n- Still appear in quota checks (marked `[disabled]`)\n- Can be re-enabled at any time\n\nThis is useful when:\n- An account is temporarily banned or rate-limited for extended periods\n- You want to reserve certain accounts for specific use cases\n- Testing with a subset of accounts\n\n---\n\n## Adding Accounts\n\nWhen running `opencode auth login` with existing accounts:\n\n```\n2 account(s) saved:\n  1. user1@gmail.com\n  2. user2@gmail.com\n\n(a)dd new account(s) or (f)resh start? [a/f]:\n```\n\nChoose `a` to add more accounts while keeping existing ones.\n\n---\n\n## Account Storage\n\nAccounts are stored in `~/.config/opencode/antigravity-accounts.json`:\n\n```json\n{\n  \"version\": 3,\n  \"accounts\": [\n    {\n      \"email\": \"user1@gmail.com\",\n      \"refreshToken\": \"1//0abc...\",\n      \"projectId\": \"my-gcp-project\",\n      \"enabled\": true\n    },\n    {\n      \"email\": \"user2@gmail.com\",\n      \"refreshToken\": \"1//0xyz...\",\n      \"enabled\": false\n    }\n  ],\n  \"activeIndex\": 0,\n  \"activeIndexByFamily\": {\n    \"claude\": 0,\n    \"gemini\": 0\n  }\n}\n```\n\n> ⚠️ **Security:** This file contains OAuth refresh tokens. Treat it like a password file.\n\n### Fields\n\n| Field | Description |\n|-------|-------------|\n| `email` | Google account email |\n| `refreshToken` | OAuth refresh token (auto-managed) |\n| `projectId` | Optional. Required for Gemini CLI models. See [Troubleshooting](TROUBLESHOOTING.md#gemini-cli-permission-error). |\n| `enabled` | Optional. Set to `false` to disable account rotation. Defaults to `true`. |\n| `activeIndex` | Currently active account index |\n| `activeIndexByFamily` | Per-model-family active account (claude/gemini tracked separately) |\n\n---\n\n## Token Revocation\n\nIf Google revokes a token (e.g., password change, security event), you'll see `invalid_grant` errors. The plugin automatically removes invalid accounts.\n\nTo manually reset:\n\n```bash\nrm ~/.config/opencode/antigravity-accounts.json\nopencode auth login\n```\n\n---\n\n## Parallel Sessions (oh-my-opencode)\n\nWhen using oh-my-opencode with parallel subagents, multiple processes may select the same account, causing rate limit errors.\n\n**Solution:** Enable PID-based offset in `antigravity.json`:\n\n```json\n{\n  \"pid_offset_enabled\": true\n}\n```\n\nThis distributes sessions across accounts based on process ID.\n\nAlternatively, add more accounts via `opencode auth login`.\n\n---\n\n## Account Selection Strategies\n\nConfigure in `antigravity.json`:\n\n```json\n{\n  \"account_selection_strategy\": \"hybrid\"\n}\n```\n\n| Strategy | Behavior | Best For |\n|----------|----------|----------|\n| `sticky` | Same account until rate-limited | Prompt cache preservation |\n| `round-robin` | Rotate to next account on every request | Maximum throughput |\n| `hybrid` | Deterministic selection based on health score + token bucket + LRU | Best overall distribution |\n\nSee [Configuration](CONFIGURATION.md#account-selection) for more details.\n"
  },
  {
    "path": "docs/TROUBLESHOOTING.md",
    "content": "# Troubleshooting\n\nCommon issues and solutions for the Antigravity Auth plugin.\n\n> **Quick Reset**: Most issues can be resolved by deleting `~/.config/opencode/antigravity-accounts.json` and running `opencode auth login` again.\n\n---\n\n## Configuration Paths (All Platforms)\n\nOpenCode uses `~/.config/opencode/` on **all platforms** including Windows.\n\n| File | Path |\n|------|------|\n| Main config | `~/.config/opencode/opencode.json` |\n| Accounts | `~/.config/opencode/antigravity-accounts.json` |\n| Plugin config | `~/.config/opencode/antigravity.json` |\n| Debug logs | `~/.config/opencode/antigravity-logs/` |\n\n> **Windows users**: `~` resolves to your user home directory (e.g., `C:\\Users\\YourName`). Do NOT use `%APPDATA%`.\n\n---\n\n## Quick Fixes\n\n### Auth problems\nDelete the token file and re-login:\n```bash\nrm ~/.config/opencode/antigravity-accounts.json\nopencode auth login\n```\n\n### \"This version of Antigravity is no longer supported\"\nThis almost always means an outdated Antigravity `User-Agent` is still being used.\n\n1) Stop any running OpenCode processes (stale processes can overwrite your accounts file):\n\n**macOS/Linux:**\n```bash\npkill -f opencode || true\n```\n\n**Windows (PowerShell):**\n```powershell\nStop-Process -Name \"opencode\" -Force -ErrorAction SilentlyContinue\n```\n\n2) Clear the plugin caches and re-login:\n\n**macOS/Linux:**\n```bash\nrm -f ~/.config/opencode/antigravity-accounts.json\nrm -rf ~/.cache/opencode/node_modules/opencode-antigravity-auth\nrm -rf ~/.bun/install/cache/opencode-antigravity-auth*\nopencode auth login\n```\n\n**Windows (PowerShell):**\n```powershell\nRemove-Item \"$env:APPDATA\\opencode\\antigravity-accounts.json\" -Force -ErrorAction SilentlyContinue\nRemove-Item \"$env:LOCALAPPDATA\\opencode\\Cache\\node_modules\\opencode-antigravity-auth\" -Recurse -Force -ErrorAction SilentlyContinue\nRemove-Item \"$env:USERPROFILE\\.bun\\install\\cache\\opencode-antigravity-auth*\" -Recurse -Force -ErrorAction SilentlyContinue\nopencode auth login\n```\n\n### \"Model not found\"\nAdd this to your `google` provider config:\n```json\n\"npm\": \"@ai-sdk/google\"\n```\n\n### Session errors\nType `continue` to trigger auto-recovery, or use `/undo` to rollback.\n\n### Configuration Key Typo\n\nThe correct key is `plugin` (singular):\n\n```json\n{\n  \"plugin\": [\"opencode-antigravity-auth@latest\"]\n}\n```\n\n**Not** `\"plugins\"` (will cause \"Unrecognized key\" error).\n\n### \"Invalid SemVer: beta\"\n\n**Error:**\n```\nInvalid SemVer\n{\n  \"name\": \"UnknownError\",\n  \"data\": {\n    \"message\": \"Error: Invalid SemVer: beta ... isOutdated (src/bun/registry.ts:...)\"\n  }\n}\n```\n\n**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.\n\n**Fix (recommended):** Re-resolve the dependency in OpenCode cache so it is pinned to a real version.\n\n**macOS / Linux:**\n```bash\ncd ~/.cache/opencode\nbun add opencode-antigravity-auth@latest\n```\n\n**Windows (PowerShell):**\n```powershell\nSet-Location \"$env:USERPROFILE\\.cache\\opencode\"\nbun add opencode-antigravity-auth@latest\n```\n\nThen restart OpenCode.\n\n> If you intentionally run beta channel, use `bun add opencode-antigravity-auth@beta` instead.\n\n---\n\n## Gemini CLI Permission Error\n\nWhen using Gemini CLI models, you may see:\n> Permission 'cloudaicompanion.companions.generateChat' denied on resource '//cloudaicompanion.googleapis.com/projects/...'\n\n**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.\n\n**Solution:**\n\n1. Go to [Google Cloud Console](https://console.cloud.google.com/)\n2. Create or select a project\n3. Enable the **Gemini for Google Cloud API** (`cloudaicompanion.googleapis.com`)\n4. Add `projectId` to your account in `~/.config/opencode/antigravity-accounts.json`:\n\n```json\n{\n  \"version\": 3,\n  \"accounts\": [\n    {\n      \"email\": \"you@gmail.com\",\n      \"refreshToken\": \"...\",\n      \"projectId\": \"your-project-id\"\n    }\n  ]\n}\n```\n\n> **Note:** For multi-account setups, add `projectId` to each account.\n\n---\n\n## Gemini 3 Models 400 Error (\"Unknown name 'parameters'\")\n\n**Error:**\n```\nInvalid JSON payload received. Unknown name \"parameters\" at 'request.tools[0]'\n```\n\n**Causes:**\n- Tool schema incompatibility with Gemini's strict protobuf validation\n- MCP servers with malformed schemas\n- Plugin version regression\n\n**Solutions:**\n1. **Update to latest beta:**\n   ```json\n   { \"plugin\": [\"opencode-antigravity-auth@beta\"] }\n   ```\n\n2. **Disable MCP servers** one-by-one to find the problematic one\n\n3. **Add npm override:**\n   ```json\n   { \"provider\": { \"google\": { \"npm\": \"@ai-sdk/google\" } } }\n   ```\n\n---\n\n## MCP Servers Causing Errors\n\nSome MCP servers have schemas incompatible with Antigravity's strict JSON format.\n\n**Diagnosis:**\n1. Disable all MCP servers in your config\n2. Enable one-by-one until error reappears\n3. Report the specific MCP in a [GitHub issue](https://github.com/NoeFabris/opencode-antigravity-auth/issues)\n\n---\n\n## Rate Limits, Shadow Bans, and Hanging Prompts\n\n**Symptoms:**\n- Prompts hang indefinitely (200 OK in logs but no response)\n- 403 \"Permission Denied\" errors even with fresh accounts\n- \"All accounts rate-limited\" but quota looks available\n- New accounts get rate-limited immediately after adding\n\n**Why this happens:**\n\nGoogle has significantly tightened quota and rate-limit enforcement. This affects ALL users, not just this plugin. Key factors:\n\n1. **Stricter enforcement** — Even when quota \"looks available,\" Google may throttle or soft-ban accounts that trigger their abuse detection\n2. **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\n3. **Shadow bans** — Some accounts become effectively unusable for extended periods once flagged, while others continue working normally\n\n> ⚠️ **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.**\n\n**Solutions:**\n\n<details>\n<summary><b>1. Wait it out (most reliable)</b></summary>\n\nRate limits typically reset after a few hours. If you're seeing persistent issues:\n- Stop using the affected account for 24-48 hours\n- Use a different account in the meantime\n- Check `rateLimitResetTimes` in your accounts file to see when limits expire\n\n</details>\n\n<details>\n<summary><b>2. \"Warm up\" accounts in Antigravity IDE (community tip)</b></summary>\n\nUsers have reported success with this approach:\n\n1. Open [Antigravity IDE](https://idx.google.com/) directly in your browser\n2. Log in with the affected Google account\n3. Run a few simple prompts (e.g., \"Hello\", \"What's 2+2?\")\n4. After 5-10 successful prompts, try using the account with the plugin again\n\n**Why this might work:** Using the account through the \"official\" interface may reset some internal flags or make the account appear less suspicious.\n\n</details>\n\n<details>\n<summary><b>3. Reduce request volume and burstiness</b></summary>\n\n- Use shorter sessions\n- Avoid parallel/retry-heavy workflows (e.g., spawning many subagents at once)\n- If using oh-my-opencode, consider reducing concurrent agent spawns\n- Set `max_rate_limit_wait_seconds: 0` to fail fast instead of retrying\n\n</details>\n\n<details>\n<summary><b>4. Use Antigravity IDE directly (single account users)</b></summary>\n\nIf 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.\n\n</details>\n\n<details>\n<summary><b>5. Fresh account setup</b></summary>\n\nIf adding new accounts:\n1. Delete accounts file: `rm ~/.config/opencode/antigravity-accounts.json`\n2. Re-authenticate: `opencode auth login`\n3. Update to latest beta: `\"plugin\": [\"opencode-antigravity-auth@beta\"]`\n4. Consider \"warming up\" the account in Antigravity IDE first\n\n</details>\n\n**What to report:**\n\nIf you're seeing unusual rate limit behavior, please share in a [GitHub issue](https://github.com/NoeFabris/opencode-antigravity-auth/issues):\n- Status codes from debug logs (403, 429, etc.)\n- How long the rate-limit state persists\n- Number of accounts and selection strategy used\n\n---\n\n## Infinite `.tmp` Files Created\n\n**Cause:** When account is rate-limited and plugin retries infinitely, it creates many temp files.\n\n**Workaround:**\n1. Stop OpenCode\n2. Clean up: `rm ~/.config/opencode/*.tmp`\n3. Add more accounts or wait for rate limit to expire\n\n---\n\n## Safari OAuth Callback Fails (macOS)\n\n**Symptoms:**\n- \"fail to authorize\" after successful Google login\n- Safari shows \"Safari can't open the page\" or connection refused\n\n**Cause:** Safari's \"HTTPS-Only Mode\" blocks the `http://localhost` callback URL.\n\n**Solutions:**\n\n1. **Use a different browser** (easiest):\n   Copy the URL from `opencode auth login` and paste it into Chrome or Firefox.\n\n2. **Temporarily disable HTTPS-Only Mode:**\n   - Safari > Settings (⌘,) > Privacy\n   - Uncheck \"Enable HTTPS-Only Mode\"\n   - Run `opencode auth login`\n   - Re-enable after authentication\n\n3. **Manual callback extraction** (advanced):\n   - When Safari shows the error, the address bar contains `?code=...&scope=...`\n   - See [issue #119](https://github.com/NoeFabris/opencode-antigravity-auth/issues/119) for manual auth support\n\n---\n\n## Port Already in Use\n\nIf OAuth fails with \"Address already in use\":\n\n**macOS / Linux:**\n```bash\nlsof -i :51121\nkill -9 <PID>\nopencode auth login\n```\n\n**Windows:**\n```powershell\nnetstat -ano | findstr :51121\ntaskkill /PID <PID> /F\nopencode auth login\n```\n\n---\n\n## WSL2 / Docker / Remote Development\n\nThe OAuth callback requires the browser to reach `localhost` on the machine running OpenCode.\n\n<details>\n<summary><b>WSL2</b></summary>\n\n- Use VS Code's port forwarding, or\n- Configure Windows → WSL port forwarding\n\n</details>\n\n<details>\n<summary><b>SSH / Remote</b></summary>\n\n```bash\nssh -L 51121:localhost:51121 user@remote\n```\n\n</details>\n\n<details>\n<summary><b>Docker / Containers</b></summary>\n\n- OAuth with localhost redirect doesn't work in containers\n- Wait 30s for manual URL flow, or use SSH port forwarding\n\n</details>\n\n---\n\n## Migrating Accounts Between Machines\n\nWhen copying `antigravity-accounts.json` to a new machine:\n1. Ensure the plugin is installed: `\"plugin\": [\"opencode-antigravity-auth@beta\"]`\n2. Copy `~/.config/opencode/antigravity-accounts.json`\n3. If you get \"API key missing\" error, the refresh token may be invalid — re-authenticate\n\n---\n\n## Plugin Compatibility Issues\n\n### @tarquinen/opencode-dcp\n\nDCP creates synthetic assistant messages that lack thinking blocks. **List this plugin BEFORE DCP:**\n\n```json\n{\n  \"plugin\": [\n    \"opencode-antigravity-auth@latest\",\n    \"@tarquinen/opencode-dcp@latest\"\n  ]\n}\n```\n\n### oh-my-opencode\n\nDisable built-in auth:\n```json\n{\n  \"google_auth\": false\n}\n```\n\nWhen spawning parallel subagents, multiple processes may hit the same account. **Workaround:** Enable `pid_offset_enabled: true` or add more accounts.\n\n### Other gemini-auth plugins\n\nYou don't need them. This plugin handles all Google OAuth.\n\n---\n\n## Migration Guides\n\n### v1.2.8+ (Variants)\n\nv1.2.8+ introduces **model variants** for dynamic thinking configuration.\n\n**Before (v1.2.7):**\n```json\n{\n  \"antigravity-claude-opus-4-6-thinking-low\": { ... },\n  \"antigravity-claude-opus-4-6-thinking-max\": { ... }\n}\n```\n\n**After (v1.2.8+):**\n```json\n{\n  \"antigravity-claude-opus-4-6-thinking\": {\n    \"variants\": {\n      \"low\": { \"thinkingConfig\": { \"thinkingBudget\": 8192 } },\n      \"max\": { \"thinkingConfig\": { \"thinkingBudget\": 32768 } }\n    }\n  }\n}\n```\n\nUse canonical model names from current docs. Deprecated model names are sent as requested and may fail if the upstream API has removed them.\n\n### v1.2.7 (Prefix)\n\nv1.2.7+ uses explicit `antigravity-` prefix:\n\n| Old Name | New Name |\n|----------|----------|\n| `gemini-3-pro-low` | `antigravity-gemini-3-pro` |\n| `claude-sonnet-4-6` | `antigravity-claude-sonnet-4-6` |\n\nUse the `antigravity-` prefixed model names shown above.\n\n---\n\n## Debugging\n\nEnable debug logging:\n```json\n{\n  \"debug\": true,\n  \"debug_tui\": true\n}\n```\n\nLogs are in `~/.config/opencode/antigravity-logs/`.\n\n---\n\n## E2E Testing\n\nThe plugin includes regression tests (consume API quota):\n\n```bash\nnpx tsx script/test-regression.ts --sanity      # 7 tests, ~5 min\nnpx tsx script/test-regression.ts --heavy       # 4 tests, ~30 min\nnpx tsx script/test-regression.ts --dry-run     # List tests\n```\n\n---\n\n## Still stuck?\n\nOpen an issue on [GitHub](https://github.com/NoeFabris/opencode-antigravity-auth/issues).\n"
  },
  {
    "path": "index.ts",
    "content": "export {\n  AntigravityCLIOAuthPlugin,\n  GoogleOAuthPlugin,\n} from \"./src/plugin\";\n\nexport {\n  authorizeAntigravity,\n  exchangeAntigravity,\n} from \"./src/antigravity/oauth\";\n\nexport type {\n  AntigravityAuthorization,\n  AntigravityTokenExchangeResult,\n} from \"./src/antigravity/oauth\";\n"
  },
  {
    "path": "package.json",
    "content": "{\n    \"name\": \"opencode-antigravity-auth\",\n    \"version\": \"1.6.0\",\n    \"description\": \"Google Antigravity IDE OAuth auth plugin for Opencode - access Gemini 3 Pro and Claude 4.6 using Google credentials\",\n    \"main\": \"./dist/index.js\",\n    \"types\": \"./dist/index.d.ts\",\n    \"type\": \"module\",\n    \"license\": \"MIT\",\n    \"author\": \"noefabris\",\n    \"repository\": {\n        \"type\": \"git\",\n        \"url\": \"git+https://github.com/NoeFabris/opencode-antigravity-auth.git\"\n    },\n    \"homepage\": \"https://github.com/NoeFabris/opencode-antigravity-auth#readme\",\n    \"bugs\": {\n        \"url\": \"https://github.com/NoeFabris/opencode-antigravity-auth/issues\"\n    },\n    \"keywords\": [\n        \"opencode\",\n        \"google\",\n        \"antigravity\",\n        \"gemini\",\n        \"oauth\",\n        \"plugin\",\n        \"auth\",\n        \"claude\"\n    ],\n    \"engines\": {\n        \"node\": \">=20.0.0\"\n    },\n    \"files\": [\n        \"dist/\",\n        \"README.md\",\n        \"LICENSE\"\n    ],\n    \"scripts\": {\n        \"build\": \"tsc -p tsconfig.build.json\",\n        \"build:schema\": \"npx tsx script/build-schema.ts\",\n        \"typecheck\": \"tsc --noEmit\",\n        \"test\": \"vitest run\",\n        \"test:watch\": \"vitest\",\n        \"test:ui\": \"vitest --ui\",\n        \"test:coverage\": \"vitest run --coverage\",\n        \"prepublishOnly\": \"npm run build\",\n        \"test:e2e:models\": \"npx tsx script/test-models.ts\",\n        \"test:e2e:regression\": \"npx tsx script/test-regression.ts\"\n    },\n    \"peerDependencies\": {\n        \"typescript\": \"^5\"\n    },\n    \"devDependencies\": {\n        \"@types/node\": \"^24.10.1\",\n        \"@types/proper-lockfile\": \"^4.1.4\",\n        \"@vitest/coverage-v8\": \"^3.0.0\",\n        \"@vitest/ui\": \"^3.0.0\",\n        \"typescript\": \"^5.0.0\",\n        \"vitest\": \"^3.0.0\",\n        \"zod-to-json-schema\": \"^3.25.1\"\n    },\n    \"dependencies\": {\n        \"@opencode-ai/plugin\": \"^0.15.30\",\n        \"@openauthjs/openauth\": \"^0.4.3\",\n        \"proper-lockfile\": \"^4.1.2\",\n        \"xdg-basedir\": \"^5.1.0\",\n        \"zod\": \"^4.0.0\"\n    }\n}"
  },
  {
    "path": "script/build-schema.ts",
    "content": "import { writeFileSync, mkdirSync } from \"node:fs\";\nimport { dirname, join } from \"node:path\";\nimport { fileURLToPath } from \"node:url\";\nimport { AntigravityConfigSchema } from \"../src/plugin/config/schema.js\";\n\nconst __dirname = dirname(fileURLToPath(import.meta.url));\nconst outputPath = join(__dirname, \"../assets/antigravity.schema.json\");\n\n// Use zod v4's built-in toJSONSchema method\nconst rawSchema = AntigravityConfigSchema.toJSONSchema({\n  unrepresentable: \"any\",\n  override: (_ctx) => undefined // Use default handling\n}) as Record<string, unknown>;\n\n// Remove the \"required\" array since all fields have defaults and are optional\n// This preserves backwards compatibility with the draft-07 schema behavior\ndelete rawSchema.required;\n\nconst optionDescriptions: Record<string, string> = {\n  quiet_mode:\n    \"Suppress most toast notifications (rate limit, account switching). Recovery toasts always shown.\",\n  debug:\n    \"Enable debug logging to file.\",\n  log_dir:\n    \"Custom directory for debug logs.\",\n  keep_thinking:\n    \"Preserve thinking blocks for Claude models using signature caching. May cause signature errors.\",\n  session_recovery:\n    \"Enable automatic session recovery from tool_result_missing errors.\",\n  auto_resume:\n    \"Automatically send resume prompt after successful recovery.\",\n  resume_text:\n    \"Custom text to send when auto-resuming after recovery.\",\n  empty_response_max_attempts:\n    \"Maximum retry attempts when Antigravity returns an empty response (no candidates).\",\n  empty_response_retry_delay_ms:\n    \"Delay in milliseconds between empty response retries.\",\n  tool_id_recovery:\n    \"Enable tool ID orphan recovery. Matches mismatched tool responses by function name or creates placeholders.\",\n  claude_tool_hardening:\n    \"Enable tool hallucination prevention for Claude models. Injects parameter signatures and strict usage rules.\",\n  claude_prompt_auto_caching:\n    \"Enable Claude prompt auto-caching by adding top-level cache_control when absent.\",\n  proactive_token_refresh:\n    \"Enable proactive background token refresh before expiry, ensuring requests never block.\",\n  proactive_refresh_buffer_seconds:\n    \"Seconds before token expiry to trigger proactive refresh.\",\n  proactive_refresh_check_interval_seconds:\n    \"Interval between proactive refresh checks in seconds.\",\n  auto_update: \"Enable automatic plugin updates.\",\n  quota_fallback:\n    \"Deprecated: accepted for backward compatibility but ignored at runtime. Gemini fallback between Antigravity and Gemini CLI is always enabled.\",\n  cli_first:\n    \"Prefer gemini-cli routing before Antigravity for Gemini models. When false (default), Antigravity is tried first and gemini-cli is fallback.\",\n  toast_scope:\n    \"Controls which sessions show toast notifications. 'root_only' (default) shows in root session only, 'all' shows in all sessions.\",\n  scheduling_mode:\n    \"Rate limit scheduling strategy. 'cache_first' (default) waits for cooldowns, 'balance' distributes across accounts, 'performance_first' picks fastest available.\",\n  max_cache_first_wait_seconds:\n    \"Maximum seconds to wait for a rate-limited account in cache_first mode before switching.\",\n  failure_ttl_seconds:\n    \"Time in seconds before a failed account is eligible for retry.\",\n  request_jitter_max_ms:\n    \"Maximum random jitter in milliseconds added to outgoing requests to avoid thundering herd.\",\n  soft_quota_threshold_percent:\n    \"Percentage of quota usage that triggers soft quota warnings and preemptive account switching.\",\n  quota_refresh_interval_minutes:\n    \"Interval in minutes between quota usage checks. Set to 0 to disable periodic checks.\",\n  soft_quota_cache_ttl_minutes:\n    \"TTL for cached soft quota data. 'auto' (default) calculates from refresh interval, or set a fixed number of minutes.\",\n};\n\nconst signatureCacheDescriptions: Record<string, string> = {\n  enabled: \"Enable disk caching of thinking block signatures.\",\n  memory_ttl_seconds: \"In-memory TTL in seconds.\",\n  disk_ttl_seconds: \"Disk TTL in seconds.\",\n  write_interval_seconds: \"Background write interval in seconds.\",\n};\n\nfunction addDescriptions(schema: Record<string, unknown>): void {\n  const props = schema.properties as Record<string, Record<string, unknown>> | undefined;\n  if (!props) return;\n\n  for (const [key, prop] of Object.entries(props)) {\n    if (optionDescriptions[key]) {\n      prop.description = optionDescriptions[key];\n    }\n\n    if (key === \"signature_cache\" && prop.properties) {\n      const cacheProps = prop.properties as Record<string, Record<string, unknown>>;\n      for (const [cacheKey, cacheProp] of Object.entries(cacheProps)) {\n        if (signatureCacheDescriptions[cacheKey]) {\n          cacheProp.description = signatureCacheDescriptions[cacheKey];\n        }\n      }\n      prop.description = \"Signature cache configuration for persisting thinking block signatures. Only used when keep_thinking is enabled.\";\n    }\n  }\n}\n\nconst definitions = rawSchema.definitions as Record<string, Record<string, unknown>> | undefined;\nif (definitions?.AntigravityConfig) {\n  addDescriptions(definitions.AntigravityConfig);\n} else {\n  addDescriptions(rawSchema);\n}\n\nmkdirSync(dirname(outputPath), { recursive: true });\nwriteFileSync(outputPath, JSON.stringify(rawSchema, null, 2) + \"\\n\");\n\nconsole.log(`Schema written to ${outputPath}`);\n"
  },
  {
    "path": "script/test-cross-model-e2e.sh",
    "content": "#!/bin/bash\n# Cross-Model E2E Test Suite - 5 Model Variants\n# Tests fix for \"Invalid `signature` in `thinking` block\" error\n#\n# Models tested:\n# 1. Gemini (google/antigravity-gemini-3-pro-low, gemini-3-flash)\n# 2. Claude via Anthropic (anthropic/claude-opus-4-5)\n# 3. Claude via Google (google/antigravity-claude-*-thinking-*)\n# 4. OpenAI (openai/gpt-5.2-medium)\n\nset -euo pipefail\n\nPASS=0\nFAIL=0\nSKIP=0\n\nGREEN='\\033[0;32m'\nRED='\\033[0;31m'\nYELLOW='\\033[0;33m'\nBLUE='\\033[0;34m'\nNC='\\033[0m'\n\nlog_pass() { echo -e \"${GREEN}✓ PASS${NC}: $1\"; ((PASS++)); }\nlog_fail() { echo -e \"${RED}✗ FAIL${NC}: $1\"; ((FAIL++)); }\nlog_skip() { echo -e \"${YELLOW}○ SKIP${NC}: $1\"; ((SKIP++)); }\nlog_info() { echo -e \"  ${BLUE}→${NC} $1\"; }\n\nget_session_id() {\n  sleep 1\n  opencode session list 2>/dev/null | grep -oP 'ses_[a-zA-Z0-9]+' | head -1 || true\n}\n\ncheck_signature_error() {\n  grep -qi \"Invalid.*signature\" \"$1\" 2>/dev/null && return 0 || return 1\n}\n\necho \"════════════════════════════════════════════════════════════\"\necho \"  Cross-Model E2E Test Suite - 5 Model Variants\"\necho \"════════════════════════════════════════════════════════════\"\necho \"\"\n\n# Test 1: Gemini → Anthropic Claude (original bug + direct Anthropic API)\necho \"Test 1: Gemini Pro → Anthropic Claude Opus (direct API)\"\nlog_info \"Step 1: Gemini with thinking + tool...\"\nopencode run -m google/antigravity-gemini-3-pro-low \\\n  \"Run: echo 'Test1-Gemini'. Think about sequences.\" \\\n  > /tmp/e2e-t1-s1.log 2>&1 || true\n\nSID=$(get_session_id)\nif [ -z \"$SID\" ]; then\n  log_fail \"Test 1 - No session ID\"\nelse\n  log_info \"Session: $SID\"\n  log_info \"Step 2: Anthropic Claude Opus + tool...\"\n  opencode run -s \"$SID\" -m anthropic/claude-opus-4-5 \\\n    \"Run: echo 'Test1-Anthropic-Claude'\" \\\n    > /tmp/e2e-t1-s2.log 2>&1 || true\n  \n  if check_signature_error /tmp/e2e-t1-s2.log; then\n    log_fail \"Test 1 - Invalid signature error (Gemini → Anthropic Claude)\"\n  else\n    log_pass \"Test 1 - Gemini → Anthropic Claude\"\n  fi\nfi\necho \"\"\n\n# Test 2: Gemini → Google Claude (Google-hosted Claude)\necho \"Test 2: Gemini Pro → Google Claude Opus Thinking\"\nlog_info \"Step 1: Gemini with thinking + tool...\"\nopencode run -m google/antigravity-gemini-3-pro-low \\\n  \"Run: echo 'Test2-Gemini'. Think about this.\" \\\n  > /tmp/e2e-t2-s1.log 2>&1 || true\n\nSID=$(get_session_id)\nif [ -z \"$SID\" ]; then\n  log_fail \"Test 2 - No session ID\"\nelse\n  log_info \"Session: $SID\"\n  log_info \"Step 2: Google Claude Opus Thinking + tool...\"\n  opencode run -s \"$SID\" -m google/antigravity-claude-opus-4-6-thinking-low \\\n    \"Run: echo 'Test2-Google-Claude'\" \\\n    > /tmp/e2e-t2-s2.log 2>&1 || true\n  \n  if check_signature_error /tmp/e2e-t2-s2.log; then\n    log_fail \"Test 2 - Invalid signature error (Gemini → Google Claude)\"\n  else\n    log_pass \"Test 2 - Gemini → Google Claude Thinking\"\n  fi\nfi\necho \"\"\n\n# Test 3: Gemini → OpenAI\necho \"Test 3: Gemini Pro → OpenAI GPT-5.2\"\nlog_info \"Step 1: Gemini with thinking + tool...\"\nopencode run -m google/antigravity-gemini-3-pro-low \\\n  \"Run: echo 'Test3-Gemini'. Think about AI models.\" \\\n  > /tmp/e2e-t3-s1.log 2>&1 || true\n\nSID=$(get_session_id)\nif [ -z \"$SID\" ]; then\n  log_fail \"Test 3 - No session ID\"\nelse\n  log_info \"Session: $SID\"\n  log_info \"Step 2: OpenAI GPT-5.2 + tool...\"\n  opencode run -s \"$SID\" -m openai/gpt-5.2-medium \\\n    \"Run: echo 'Test3-OpenAI'\" \\\n    > /tmp/e2e-t3-s2.log 2>&1 || true\n  \n  if check_signature_error /tmp/e2e-t3-s2.log; then\n    log_fail \"Test 3 - Invalid signature error (Gemini → OpenAI)\"\n  elif grep -qi \"api.*key\\|unauthorized\\|authentication\" /tmp/e2e-t3-s2.log; then\n    log_skip \"Test 3 - OpenAI API key issue (not signature related)\"\n  else\n    log_pass \"Test 3 - Gemini → OpenAI\"\n  fi\nfi\necho \"\"\n\n# Test 4: Anthropic Claude → Gemini (reverse)\necho \"Test 4: Anthropic Claude → Gemini (reverse direction)\"\nlog_info \"Step 1: Anthropic Claude with tool...\"\nopencode run -m anthropic/claude-opus-4-5 \\\n  \"Run: echo 'Test4-Anthropic-Start'\" \\\n  > /tmp/e2e-t4-s1.log 2>&1 || true\n\nSID=$(get_session_id)\nif [ -z \"$SID\" ]; then\n  log_fail \"Test 4 - No session ID\"\nelse\n  log_info \"Session: $SID\"\n  log_info \"Step 2: Gemini + thinking + tool...\"\n  opencode run -s \"$SID\" -m google/antigravity-gemini-3-pro-low \\\n    \"Run: echo 'Test4-Gemini'. Think about reversal.\" \\\n    > /tmp/e2e-t4-s2.log 2>&1 || true\n  \n  if check_signature_error /tmp/e2e-t4-s2.log; then\n    log_fail \"Test 4 - Invalid signature error (Anthropic Claude → Gemini)\"\n  else\n    log_pass \"Test 4 - Anthropic Claude → Gemini\"\n  fi\nfi\necho \"\"\n\n# Test 5: OpenAI → Google Claude\necho \"Test 5: OpenAI → Google Claude Opus Thinking\"\nlog_info \"Step 1: OpenAI with tool...\"\nopencode run -m openai/gpt-5.2-medium \\\n  \"Run: echo 'Test5-OpenAI-Start'\" \\\n  > /tmp/e2e-t5-s1.log 2>&1 || true\n\nif grep -qi \"api.*key\\|unauthorized\\|authentication\" /tmp/e2e-t5-s1.log; then\n  log_skip \"Test 5 - OpenAI API key issue\"\nelse\n  SID=$(get_session_id)\n  if [ -z \"$SID\" ]; then\n    log_fail \"Test 5 - No session ID\"\n  else\n    log_info \"Session: $SID\"\n    log_info \"Step 2: Google Claude Opus Thinking + tool...\"\n    opencode run -s \"$SID\" -m google/antigravity-claude-opus-4-6-thinking-low \\\n      \"Run: echo 'Test5-Google-Claude'\" \\\n      > /tmp/e2e-t5-s2.log 2>&1 || true\n    \n    if check_signature_error /tmp/e2e-t5-s2.log; then\n      log_fail \"Test 5 - Invalid signature error (OpenAI → Google Claude)\"\n    else\n      log_pass \"Test 5 - OpenAI → Google Claude\"\n    fi\n  fi\nfi\necho \"\"\n\n# Test 6: 5-Model Round-Robin (all models in sequence)\necho \"Test 6: 5-Model Round-Robin\"\nlog_info \"Turn 1: Gemini Pro Low...\"\nopencode run -m google/antigravity-gemini-3-pro-low \\\n  \"Run: echo 'Turn1'. Think about the chain.\" \\\n  > /tmp/e2e-t6-s1.log 2>&1 || true\n\nSID=$(get_session_id)\nif [ -z \"$SID\" ]; then\n  log_fail \"Test 6 - No session ID\"\nelse\n  log_info \"Session: $SID\"\n  CHAIN_OK=true\n  \n  log_info \"Turn 2: Anthropic Claude...\"\n  opencode run -s \"$SID\" -m anthropic/claude-opus-4-5 \\\n    \"Run: echo 'Turn2'\" > /tmp/e2e-t6-s2.log 2>&1 || true\n  check_signature_error /tmp/e2e-t6-s2.log && CHAIN_OK=false\n  \n  log_info \"Turn 3: Google Claude Opus...\"\n  opencode run -s \"$SID\" -m google/antigravity-claude-opus-4-6-thinking-low \\\n    \"Run: echo 'Turn3'\" > /tmp/e2e-t6-s3.log 2>&1 || true\n  check_signature_error /tmp/e2e-t6-s3.log && CHAIN_OK=false\n  \n  log_info \"Turn 4: OpenAI GPT-5.2...\"\n  opencode run -s \"$SID\" -m openai/gpt-5.2-medium \\\n    \"Run: echo 'Turn4'\" > /tmp/e2e-t6-s4.log 2>&1 || true\n  # Skip OpenAI check if API key issue\n  if ! grep -qi \"api.*key\\|unauthorized\" /tmp/e2e-t6-s4.log; then\n    check_signature_error /tmp/e2e-t6-s4.log && CHAIN_OK=false\n  fi\n  \n  log_info \"Turn 5: Gemini Flash...\"\n  opencode run -s \"$SID\" -m google/antigravity-gemini-3-flash \\\n    \"Run: echo 'Turn5-Complete'\" > /tmp/e2e-t6-s5.log 2>&1 || true\n  check_signature_error /tmp/e2e-t6-s5.log && CHAIN_OK=false\n  \n  if $CHAIN_OK; then\n    log_pass \"Test 6 - 5-Model Round-Robin\"\n  else\n    log_fail \"Test 6 - 5-Model Round-Robin (signature error in chain)\"\n  fi\nfi\necho \"\"\n\n# Test 7: Google Claude → Anthropic Claude (same family, different API)\necho \"Test 7: Google Claude → Anthropic Claude (same family)\"\nlog_info \"Step 1: Google Claude Opus Thinking...\"\nopencode run -m google/antigravity-claude-opus-4-6-thinking-low \\\n  \"Run: echo 'Test7-Google-Claude'\" \\\n  > /tmp/e2e-t7-s1.log 2>&1 || true\n\nSID=$(get_session_id)\nif [ -z \"$SID\" ]; then\n  log_fail \"Test 7 - No session ID\"\nelse\n  log_info \"Session: $SID\"\n  log_info \"Step 2: Anthropic Claude Opus...\"\n  opencode run -s \"$SID\" -m anthropic/claude-opus-4-5 \\\n    \"Run: echo 'Test7-Anthropic-Claude'\" \\\n    > /tmp/e2e-t7-s2.log 2>&1 || true\n  \n  if check_signature_error /tmp/e2e-t7-s2.log; then\n    log_fail \"Test 7 - Invalid signature error (Google Claude → Anthropic Claude)\"\n  else\n    log_pass \"Test 7 - Google Claude → Anthropic Claude\"\n  fi\nfi\necho \"\"\n\n# Test 8: Triple switch with different model families\necho \"Test 8: Triple Switch (Gemini → Anthropic → OpenAI)\"\nlog_info \"Step 1: Gemini Flash...\"\nopencode run -m google/antigravity-gemini-3-flash \\\n  \"Run: echo 'Triple-1'. Think about it.\" \\\n  > /tmp/e2e-t8-s1.log 2>&1 || true\n\nSID=$(get_session_id)\nif [ -z \"$SID\" ]; then\n  log_fail \"Test 8 - No session ID\"\nelse\n  log_info \"Session: $SID\"\n  TRIPLE_OK=true\n  \n  log_info \"Step 2: Anthropic Claude...\"\n  opencode run -s \"$SID\" -m anthropic/claude-opus-4-5 \\\n    \"Run: echo 'Triple-2'\" > /tmp/e2e-t8-s2.log 2>&1 || true\n  check_signature_error /tmp/e2e-t8-s2.log && TRIPLE_OK=false\n  \n  log_info \"Step 3: OpenAI...\"\n  opencode run -s \"$SID\" -m openai/gpt-5.2-medium \\\n    \"Run: echo 'Triple-3'\" > /tmp/e2e-t8-s3.log 2>&1 || true\n  if ! grep -qi \"api.*key\\|unauthorized\" /tmp/e2e-t8-s3.log; then\n    check_signature_error /tmp/e2e-t8-s3.log && TRIPLE_OK=false\n  fi\n  \n  if $TRIPLE_OK; then\n    log_pass \"Test 8 - Triple Switch\"\n  else\n    log_fail \"Test 8 - Triple Switch (signature error)\"\n  fi\nfi\necho \"\"\n\necho \"════════════════════════════════════════════════════════════\"\necho \"  Test Results Summary\"\necho \"════════════════════════════════════════════════════════════\"\necho -e \"  ${GREEN}Passed${NC}:  $PASS\"\necho -e \"  ${RED}Failed${NC}:  $FAIL\"\necho -e \"  ${YELLOW}Skipped${NC}: $SKIP\"\necho \"\"\n\nif [ $FAIL -gt 0 ]; then\n  echo -e \"${RED}Some tests failed!${NC} Check /tmp/e2e-t*.log for details\"\n  exit 1\nelse\n  echo -e \"${GREEN}All tests passed!${NC}\"\n  exit 0\nfi\n"
  },
  {
    "path": "script/test-cross-model.ts",
    "content": "#!/usr/bin/env npx tsx\nimport {\n  sanitizeCrossModelPayload,\n  getModelFamily,\n} from '../src/plugin/transform/cross-model-sanitizer';\n\nconst GEMINI_THOUGHT_SIGNATURE = 'EsgQCsUQAXLI2nybuafAE150LGTo2r78fakesig123abc456def789';\n\nconst geminiHistoryWithThinkingAndToolCall = {\n  contents: [\n    {\n      role: 'user',\n      parts: [{ text: 'Check disk space. Think about which filesystems are most utilized.' }]\n    },\n    {\n      role: 'model',\n      parts: [\n        {\n          thought: true,\n          text: 'Let me analyze the disk usage by running df -h to see filesystem utilization...',\n          thoughtSignature: GEMINI_THOUGHT_SIGNATURE\n        },\n        {\n          functionCall: { \n            name: 'Bash', \n            args: { command: 'df -h', description: 'Check disk space' } \n          },\n          metadata: {\n            google: {\n              thoughtSignature: GEMINI_THOUGHT_SIGNATURE\n            }\n          }\n        }\n      ]\n    },\n    {\n      role: 'function',\n      parts: [{\n        functionResponse: {\n          name: 'Bash',\n          response: { \n            output: 'Filesystem      Size  Used Avail Use% Mounted on\\n/dev/sda1       100G   62G   38G  62% /' \n          }\n        }\n      }]\n    },\n    {\n      role: 'model',\n      parts: [{ text: 'The root filesystem is 62% utilized, which is moderate usage.' }]\n    }\n  ]\n};\n\nfunction runTests(): void {\n  console.log('=== Cross-Model Sanitization E2E Test ===\\n');\n  \n  let passed = 0;\n  let failed = 0;\n\n  console.log('Test 1: Model family detection');\n  const geminiFamily = getModelFamily('gemini-3-pro-low');\n  const claudeFamily = getModelFamily('claude-opus-4-6-thinking-medium');\n  if (geminiFamily === 'gemini' && claudeFamily === 'claude') {\n    console.log('  ✅ PASS: Model families detected correctly');\n    passed++;\n  } else {\n    console.log(`  ❌ FAIL: Expected gemini/claude, got ${geminiFamily}/${claudeFamily}`);\n    failed++;\n  }\n\n  console.log('\\nTest 2: Gemini → Claude sanitization (exact bug reproduction)');\n  console.log('  Input: Gemini session with thinking + tool call containing thoughtSignature');\n  \n  const result = sanitizeCrossModelPayload(geminiHistoryWithThinkingAndToolCall, {\n    targetModel: 'claude-opus-4-6-thinking-medium'\n  });\n\n  const payload = result.payload as any;\n  const modelParts = payload.contents[1].parts;\n  const thinkingPart = modelParts[0];\n  const toolPart = modelParts[1];\n\n  if (thinkingPart.thoughtSignature === undefined) {\n    console.log('  ✅ PASS: Top-level thoughtSignature stripped from thinking part');\n    passed++;\n  } else {\n    console.log('  ❌ FAIL: thoughtSignature still present on thinking part');\n    failed++;\n  }\n\n  if (toolPart.metadata?.google?.thoughtSignature === undefined) {\n    console.log('  ✅ PASS: Nested metadata.google.thoughtSignature stripped from tool part');\n    passed++;\n  } else {\n    console.log('  ❌ FAIL: metadata.google.thoughtSignature still present');\n    failed++;\n  }\n\n  if (toolPart.functionCall?.name === 'Bash') {\n    console.log('  ✅ PASS: functionCall structure preserved');\n    passed++;\n  } else {\n    console.log('  ❌ FAIL: functionCall corrupted');\n    failed++;\n  }\n\n  if (result.modified && result.signaturesStripped === 2) {\n    console.log(`  ✅ PASS: Sanitization metrics correct (modified=true, stripped=${result.signaturesStripped})`);\n    passed++;\n  } else {\n    console.log(`  ❌ FAIL: Metrics incorrect (modified=${result.modified}, stripped=${result.signaturesStripped})`);\n    failed++;\n  }\n\n  console.log('\\nTest 3: Same model family - no sanitization');\n  const sameFamily = sanitizeCrossModelPayload(geminiHistoryWithThinkingAndToolCall, {\n    targetModel: 'gemini-3-flash'\n  });\n\n  if (!sameFamily.modified && sameFamily.signaturesStripped === 0) {\n    console.log('  ✅ PASS: No sanitization for same model family');\n    passed++;\n  } else {\n    console.log('  ❌ FAIL: Should not sanitize same model family');\n    failed++;\n  }\n\n  console.log('\\n=== Results ===');\n  console.log(`Passed: ${passed}/${passed + failed}`);\n  console.log(`Failed: ${failed}/${passed + failed}`);\n  \n  if (failed > 0) {\n    console.log('\\n❌ Some tests failed');\n    process.exit(1);\n  } else {\n    console.log('\\n✅ All E2E tests passed');\n  }\n}\n\nrunTests();\n"
  },
  {
    "path": "script/test-gemini-cli-e2e.sh",
    "content": "#!/bin/bash\n# Gemini CLI E2E Test Suite\n# Tests gemini-cli models routing through cloudcode-pa.googleapis.com/v1internal\n#\n# Models tested:\n# 1. google/gemini-2.5-pro\n# 2. google/gemini-2.5-flash\n# 3. google/gemini-3-pro-preview\n# 4. google/gemini-3-flash-preview\n\nset -euo pipefail\n\nPASS=0\nFAIL=0\nSKIP=0\n\nGREEN='\\033[0;32m'\nRED='\\033[0;31m'\nYELLOW='\\033[0;33m'\nBLUE='\\033[0;34m'\nNC='\\033[0m'\n\nlog_pass() { echo -e \"${GREEN}✓ PASS${NC}: $1\"; ((PASS++)); }\nlog_fail() { echo -e \"${RED}✗ FAIL${NC}: $1\"; ((FAIL++)); }\nlog_skip() { echo -e \"${YELLOW}○ SKIP${NC}: $1\"; ((SKIP++)); }\nlog_info() { echo -e \"  ${BLUE}→${NC} $1\"; }\n\n# Check for common errors\ncheck_auth_error() {\n  grep -qiE \"insufficient.*scope|authentication|unauthorized|403|401\" \"$1\" 2>/dev/null && return 0 || return 1\n}\n\ncheck_quota_error() {\n  grep -qiE \"quota|rate.limit|429|resource.exhausted\" \"$1\" 2>/dev/null && return 0 || return 1\n}\n\ncheck_model_error() {\n  grep -qiE \"model.*not.found|invalid.*model|404\" \"$1\" 2>/dev/null && return 0 || return 1\n}\n\n# Test a single model\ntest_model() {\n  local model=\"$1\"\n  local test_name=\"$2\"\n  local log_file=\"/tmp/gemini-cli-e2e-${test_name}.log\"\n  \n  log_info \"Testing $model...\"\n  \n  # Run opencode with a simple prompt\n  timeout 60 opencode run -m \"$model\" \\\n    \"Reply with exactly: GEMINI_CLI_OK\" \\\n    2>&1 > \"$log_file\" || true\n  \n  # Check for various error conditions\n  if check_auth_error \"$log_file\"; then\n    log_fail \"$test_name - Authentication/scope error (check OAuth scopes)\"\n    log_info \"This likely means routing to wrong endpoint\"\n    return 1\n  elif check_quota_error \"$log_file\"; then\n    log_skip \"$test_name - Quota exhausted (not a routing issue)\"\n    return 0\n  elif check_model_error \"$log_file\"; then\n    log_fail \"$test_name - Model not found\"\n    return 1\n  elif grep -qi \"GEMINI_CLI_OK\\|working\\|ok\\|hello\" \"$log_file\"; then\n    log_pass \"$test_name\"\n    return 0\n  elif grep -qi \"error\\|exception\\|failed\" \"$log_file\"; then\n    log_fail \"$test_name - Unknown error\"\n    log_info \"Check $log_file for details\"\n    return 1\n  else\n    # No obvious error, assume success\n    log_pass \"$test_name\"\n    return 0\n  fi\n}\n\necho \"════════════════════════════════════════════════════════════\"\necho \"  Gemini CLI E2E Test Suite\"\necho \"  Testing cloudcode-pa.googleapis.com/v1internal routing\"\necho \"════════════════════════════════════════════════════════════\"\necho \"\"\n\necho \"Test 1: google/gemini-2.5-flash\"\ntest_model \"google/gemini-2.5-flash\" \"gemini-2.5-flash\" || true\necho \"\"\n\necho \"Test 2: google/gemini-2.5-pro\"\ntest_model \"google/gemini-2.5-pro\" \"gemini-2.5-pro\" || true\necho \"\"\n\necho \"Test 3: google/gemini-3-flash-preview\"\ntest_model \"google/gemini-3-flash-preview\" \"gemini-3-flash-preview\" || true\necho \"\"\n\necho \"Test 4: google/gemini-3-pro-preview\"\ntest_model \"google/gemini-3-pro-preview\" \"gemini-3-pro-preview\" || true\necho \"\"\n\n# Test 5: Cross-model session (gemini-cli → antigravity)\necho \"Test 5: Cross-model session (gemini-cli → antigravity-gemini)\"\nlog_info \"Step 1: Start with gemini-2.5-flash...\"\ntimeout 60 opencode run -m google/gemini-2.5-flash \\\n  \"Say: SESSION_START\" \\\n  2>&1 > /tmp/gemini-cli-e2e-cross-s1.log || true\n\n# Get session ID\nsleep 1\nSID=$(opencode session list 2>/dev/null | grep -oP 'ses_[a-zA-Z0-9]+' | head -1 || true)\n\nif [ -z \"$SID\" ]; then\n  log_fail \"Test 5 - No session ID created\"\nelse\n  log_info \"Session: $SID\"\n  log_info \"Step 2: Switch to antigravity-gemini-3-flash...\"\n  timeout 60 opencode run -s \"$SID\" -m google/antigravity-gemini-3-flash \\\n    \"Say: SESSION_CONTINUE\" \\\n    2>&1 > /tmp/gemini-cli-e2e-cross-s2.log || true\n  \n  if check_auth_error /tmp/gemini-cli-e2e-cross-s2.log; then\n    log_fail \"Test 5 - Auth error on cross-model switch\"\n  else\n    log_pass \"Test 5 - Cross-model session (gemini-cli → antigravity)\"\n  fi\nfi\necho \"\"\n\n# Test 6: Reverse cross-model (antigravity → gemini-cli)\necho \"Test 6: Cross-model session (antigravity → gemini-cli)\"\nlog_info \"Step 1: Start with antigravity-gemini-3-pro-low...\"\ntimeout 60 opencode run -m google/antigravity-gemini-3-pro-low \\\n  \"Say: ANTIGRAVITY_START\" \\\n  2>&1 > /tmp/gemini-cli-e2e-reverse-s1.log || true\n\nsleep 1\nSID=$(opencode session list 2>/dev/null | grep -oP 'ses_[a-zA-Z0-9]+' | head -1 || true)\n\nif [ -z \"$SID\" ]; then\n  log_fail \"Test 6 - No session ID created\"\nelse\n  log_info \"Session: $SID\"\n  log_info \"Step 2: Switch to gemini-2.5-pro...\"\n  timeout 60 opencode run -s \"$SID\" -m google/gemini-2.5-pro \\\n    \"Say: GEMINI_CLI_CONTINUE\" \\\n    2>&1 > /tmp/gemini-cli-e2e-reverse-s2.log || true\n  \n  if check_auth_error /tmp/gemini-cli-e2e-reverse-s2.log; then\n    log_fail \"Test 6 - Auth error on reverse cross-model switch\"\n  else\n    log_pass \"Test 6 - Cross-model session (antigravity → gemini-cli)\"\n  fi\nfi\necho \"\"\n\necho \"════════════════════════════════════════════════════════════\"\necho \"  Test Results Summary\"\necho \"════════════════════════════════════════════════════════════\"\necho -e \"  ${GREEN}Passed${NC}:  $PASS\"\necho -e \"  ${RED}Failed${NC}:  $FAIL\"\necho -e \"  ${YELLOW}Skipped${NC}: $SKIP\"\necho \"\"\n\nif [ $FAIL -gt 0 ]; then\n  echo -e \"${RED}Some tests failed!${NC}\"\n  echo \"Log files: /tmp/gemini-cli-e2e-*.log\"\n  exit 1\nelse\n  echo -e \"${GREEN}All Gemini CLI tests passed!${NC}\"\n  exit 0\nfi\n"
  },
  {
    "path": "script/test-models.ts",
    "content": "#!/usr/bin/env npx tsx\nimport { spawn } from \"child_process\";\n\ninterface ModelTest {\n  model: string;\n  category: \"gemini-cli\" | \"antigravity-gemini\" | \"antigravity-claude\";\n}\n\nconst MODELS: ModelTest[] = [\n  // Gemini CLI (direct Google API)\n  { model: \"google/gemini-3-flash-preview\", category: \"gemini-cli\" },\n  { model: \"google/gemini-3-pro-preview\", category: \"gemini-cli\" },\n  { model: \"google/gemini-2.5-pro\", category: \"gemini-cli\" },\n  { model: \"google/gemini-2.5-flash\", category: \"gemini-cli\" },\n\n  // Antigravity Gemini\n  { model: \"google/antigravity-gemini-3-pro-low\", category: \"antigravity-gemini\" },\n  { model: \"google/antigravity-gemini-3-pro-high\", category: \"antigravity-gemini\" },\n  { model: \"google/antigravity-gemini-3-flash\", category: \"antigravity-gemini\" },\n\n  // Antigravity Claude\n  { model: \"google/antigravity-claude-sonnet-4-6\", category: \"antigravity-claude\" },\n  { model: \"google/antigravity-claude-opus-4-6-thinking-low\", category: \"antigravity-claude\" },\n  { model: \"google/antigravity-claude-opus-4-6-thinking-medium\", category: \"antigravity-claude\" },\n  { model: \"google/antigravity-claude-opus-4-6-thinking-high\", category: \"antigravity-claude\" },\n];\n\nconst TEST_PROMPT = \"Reply with exactly one word: WORKING\";\nconst DEFAULT_TIMEOUT_MS = 120_000;\n\ninterface TestResult {\n  success: boolean;\n  error?: string;\n  duration: number;\n}\n\nasync function testModel(model: string, timeoutMs: number): Promise<TestResult> {\n  const start = Date.now();\n\n  return new Promise((resolve) => {\n    const proc = spawn(\"opencode\", [\"run\", TEST_PROMPT, \"--model\", model], {\n      stdio: [\"ignore\", \"pipe\", \"pipe\"],\n    });\n\n    let stdout = \"\";\n    let stderr = \"\";\n    const timer = setTimeout(() => {\n      proc.kill(\"SIGKILL\");\n      resolve({ success: false, error: `Timeout after ${timeoutMs}ms`, duration: Date.now() - start });\n    }, timeoutMs);\n\n    proc.stdout?.on(\"data\", (data) => { stdout += data.toString(); });\n    proc.stderr?.on(\"data\", (data) => { stderr += data.toString(); });\n\n    proc.on(\"close\", (code) => {\n      clearTimeout(timer);\n      const duration = Date.now() - start;\n\n      if (code !== 0) {\n        resolve({ success: false, error: `Exit ${code}: ${stderr || stdout}`.slice(0, 200), duration });\n      } else {\n        resolve({ success: true, duration });\n      }\n    });\n\n    proc.on(\"error\", (err) => {\n      clearTimeout(timer);\n      resolve({ success: false, error: err.message, duration: Date.now() - start });\n    });\n  });\n}\n\nfunction parseArgs(): { filterModel: string | null; filterCategory: string | null; dryRun: boolean; help: boolean; timeout: number } {\n  const args = process.argv.slice(2);\n  const modelIdx = args.indexOf(\"--model\");\n  const catIdx = args.indexOf(\"--category\");\n  const timeoutIdx = args.indexOf(\"--timeout\");\n\n  return {\n    filterModel: modelIdx !== -1 ? args[modelIdx + 1] ?? null : null,\n    filterCategory: catIdx !== -1 ? args[catIdx + 1] ?? null : null,\n    dryRun: args.includes(\"--dry-run\"),\n    help: args.includes(\"--help\") || args.includes(\"-h\"),\n    timeout: timeoutIdx !== -1 ? parseInt(args[timeoutIdx + 1] || \"120000\", 10) : DEFAULT_TIMEOUT_MS,\n  };\n}\n\nfunction printHelp(): void {\n  console.log(`\nE2E Model Test Script\n\nUsage:\n  npx tsx script/test-models.ts [options]\n\nOptions:\n  --model <model>      Test specific model\n  --category <cat>     Test by category (gemini-cli, antigravity-gemini, antigravity-claude)\n  --timeout <ms>       Timeout per model (default: 120000)\n  --dry-run            List models without testing\n  --help, -h           Show this help\n\nExamples:\n  npx tsx script/test-models.ts --dry-run\n  npx tsx script/test-models.ts --model google/gemini-3-flash-preview\n  npx tsx script/test-models.ts --category antigravity-claude\n`);\n}\n\nasync function main(): Promise<void> {\n  const { filterModel, filterCategory, dryRun, help, timeout } = parseArgs();\n\n  if (help) {\n    printHelp();\n    return;\n  }\n\n  let tests = MODELS;\n  if (filterModel) tests = tests.filter((t) => t.model === filterModel || t.model.endsWith(filterModel));\n  if (filterCategory) tests = tests.filter((t) => t.category === filterCategory);\n\n  if (tests.length === 0) {\n    console.log(\"No models match the filter.\");\n    return;\n  }\n\n  console.log(`\\n🧪 E2E Model Tests (${tests.length} models)\\n${\"=\".repeat(50)}\\n`);\n\n  if (dryRun) {\n    for (const t of tests) {\n      console.log(`  ${t.model.padEnd(50)} [${t.category}]`);\n    }\n    console.log(`\\n${tests.length} models would be tested.\\n`);\n    return;\n  }\n\n  let passed = 0;\n  let failed = 0;\n  const failures: { model: string; error: string }[] = [];\n\n  for (const t of tests) {\n    process.stdout.write(`Testing ${t.model.padEnd(50)} ... `);\n    const result = await testModel(t.model, timeout);\n\n    if (result.success) {\n      console.log(`✅ (${(result.duration / 1000).toFixed(1)}s)`);\n      passed++;\n    } else {\n      console.log(`❌ FAIL`);\n      console.log(`   ${result.error}`);\n      failures.push({ model: t.model, error: result.error || \"Unknown\" });\n      failed++;\n    }\n  }\n\n  console.log(`\\n${\"=\".repeat(50)}`);\n  console.log(`Summary: ${passed} passed, ${failed} failed\\n`);\n\n  if (failures.length > 0) {\n    console.log(\"Failed models:\");\n    for (const f of failures) {\n      console.log(`  - ${f.model}`);\n    }\n    process.exit(1);\n  }\n}\n\nmain().catch(console.error);\n"
  },
  {
    "path": "script/test-regression.ts",
    "content": "#!/usr/bin/env npx tsx\nimport { spawn } from \"child_process\";\n\ntype Category = \"thinking-order\" | \"tool-pairing\" | \"multi-tool\" | \"multi-provider\" | \"error-handling\" | \"stress\" | \"concurrency\";\ntype TestSuite = \"sanity\" | \"heavy\" | \"all\";\n\ninterface MultiTurnTest {\n  name: string;\n  model: string;\n  category: Category;\n  suite: TestSuite;\n  turns: (string | TurnConfig)[];\n  errorPatterns: string[];\n  timeout: number;\n  expectError?: string;\n}\n\ninterface TurnConfig {\n  prompt: string;\n  model?: string;\n}\n\ninterface TestResult {\n  success: boolean;\n  error?: string;\n  duration: number;\n  turnsCompleted: number;\n  sessionId?: string;\n}\n\ninterface ConcurrentTest {\n  name: string;\n  category: \"concurrency\";\n  suite: TestSuite;\n  concurrentRequests: number;\n  model: string;\n  prompt: string;\n  errorPatterns: string[];\n  timeout: number;\n}\n\nconst ERROR_PATTERNS = [\n  \"thinking block order\",\n  \"Expected thinking or redacted_thinking\",\n  \"tool_use ids were found without tool_result\",\n  \"tool_result_missing\",\n  \"thinking_disabled_violation\",\n  \"orphaned tool_use\",\n  \"must start with thinking block\",\n  \"error: tool_use without matching tool_result\",\n  \"cannot be modified\",\n  \"must remain as they were\",\n];\n\nconst GEMINI_FLASH = \"google/antigravity-gemini-3-flash\";\nconst GEMINI_FLASH_CLI_QUOTA = \"google/gemini-2.5-flash\";\nconst CLAUDE_SONNET = \"google/antigravity-claude-sonnet-4-6\";\nconst CLAUDE_OPUS = \"google/antigravity-claude-opus-4-6-thinking-low\";\n\nconst SANITY_TESTS: MultiTurnTest[] = [\n  {\n    name: \"thinking-tool-use\",\n    model: CLAUDE_SONNET,\n    category: \"thinking-order\",\n    suite: \"sanity\",\n    turns: [\"Read package.json and tell me the package name\"],\n    errorPatterns: ERROR_PATTERNS,\n    timeout: 90000,\n  },\n  {\n    name: \"thinking-bash-tool\",\n    model: CLAUDE_SONNET,\n    category: \"thinking-order\",\n    suite: \"sanity\",\n    turns: [\"Run: echo 'hello' and tell me the output\"],\n    errorPatterns: ERROR_PATTERNS,\n    timeout: 90000,\n  },\n  {\n    name: \"tool-pairing-sequential\",\n    model: CLAUDE_SONNET,\n    category: \"tool-pairing\",\n    suite: \"sanity\",\n    turns: [\"Run: echo 'first'\", \"Run: echo 'second'\"],\n    errorPatterns: ERROR_PATTERNS,\n    timeout: 120000,\n  },\n  {\n    name: \"opus-thinking-basic\",\n    model: CLAUDE_OPUS,\n    category: \"thinking-order\",\n    suite: \"sanity\",\n    turns: [\"What is 7 * 8? Use bash to verify: echo $((7*8))\"],\n    errorPatterns: ERROR_PATTERNS,\n    timeout: 120000,\n  },\n  {\n    name: \"thinking-modification-continue\",\n    model: CLAUDE_SONNET,\n    category: \"thinking-order\",\n    suite: \"sanity\",\n    turns: [\n      \"Read package.json and tell me the version\",\n      \"Now read tsconfig.json and tell me the target\",\n      \"Compare the two files briefly\",\n    ],\n    errorPatterns: ERROR_PATTERNS,\n    timeout: 120000,\n  },\n  {\n    name: \"multi-provider-switch\",\n    model: GEMINI_FLASH,\n    category: \"multi-provider\",\n    suite: \"sanity\",\n    turns: [\n      { prompt: \"What is 2+2? Answer briefly.\", model: GEMINI_FLASH },\n      { prompt: \"What is 3+3? Answer briefly.\", model: CLAUDE_SONNET },\n      { prompt: \"What is 4+4? Answer briefly.\", model: GEMINI_FLASH },\n    ],\n    errorPatterns: ERROR_PATTERNS,\n    timeout: 180000,\n  },\n  {\n    name: \"prompt-too-long-recovery\",\n    model: GEMINI_FLASH,\n    category: \"error-handling\",\n    suite: \"sanity\",\n    turns: [\"Reply with exactly: OK\", \"Repeat the word 'test' 50000 times\"],\n    errorPatterns: [\"FATAL\", \"unhandled\", \"Cannot read properties\"],\n    timeout: 60000,\n  },\n];\n\nconst HEAVY_TESTS: MultiTurnTest[] = [\n  {\n    name: \"stress-8-turn-multi-provider\",\n    model: GEMINI_FLASH,\n    category: \"stress\",\n    suite: \"heavy\",\n    turns: [\n      { prompt: \"Read package.json and tell me the name\", model: GEMINI_FLASH },\n      { prompt: \"Now read tsconfig.json and tell me the target\", model: CLAUDE_SONNET },\n      { prompt: \"Run: ls -la src/plugin | head -5\", model: GEMINI_FLASH },\n      { prompt: \"Read src/plugin/auth.ts and summarize in 1 sentence\", model: CLAUDE_SONNET },\n      { prompt: \"Run: wc -l src/plugin/*.ts | tail -3\", model: GEMINI_FLASH },\n      { prompt: \"Read README.md first 50 lines and tell me what this project does\", model: CLAUDE_SONNET },\n      { prompt: \"Run: git log --oneline -3\", model: GEMINI_FLASH },\n      { prompt: \"Summarize everything we discussed in 3 bullet points\", model: CLAUDE_SONNET },\n    ],\n    errorPatterns: ERROR_PATTERNS,\n    timeout: 600000,\n  },\n  {\n    name: \"opencode-tools-comprehensive\",\n    model: CLAUDE_SONNET,\n    category: \"multi-tool\",\n    suite: \"heavy\",\n    turns: [\n      \"Use glob to find all *.ts files in src/plugin directory\",\n      \"Use grep to search for 'async function' in src/plugin/auth.ts\",\n      \"Use bash to run: echo 'test123' && pwd\",\n      \"Use read to read the first 20 lines of package.json\",\n      \"Use lsp_diagnostics on src/plugin/auth.ts to check for errors\",\n      \"Use glob to find all test files matching *.test.ts\",\n    ],\n    errorPatterns: ERROR_PATTERNS,\n    timeout: 480000,\n  },\n  {\n    name: \"stress-20-turn-recovery\",\n    model: GEMINI_FLASH,\n    category: \"stress\",\n    suite: \"heavy\",\n    turns: [\n      { prompt: \"Read package.json and extract the version number only\", model: GEMINI_FLASH },\n      { prompt: \"Run: ls src/plugin/*.ts | head -3\", model: CLAUDE_SONNET },\n      { prompt: \"Read src/plugin/auth.ts first 30 lines\", model: GEMINI_FLASH },\n      { prompt: \"Use grep to find 'export' in src/plugin/auth.ts\", model: CLAUDE_SONNET },\n      { prompt: \"Run: echo 'checkpoint 1' && date\", model: GEMINI_FLASH },\n      { prompt: \"Read tsconfig.json and tell me the module type\", model: CLAUDE_SONNET },\n      { prompt: \"Use glob to find all *.test.ts files\", model: GEMINI_FLASH },\n      { prompt: \"Read src/plugin/token.ts first 20 lines\", model: CLAUDE_SONNET },\n      { prompt: \"Run: wc -l src/plugin/*.ts | sort -n | tail -5\", model: GEMINI_FLASH },\n      { prompt: \"What files have we read so far? List them.\", model: CLAUDE_SONNET },\n      { prompt: \"Read src/plugin/request.ts first 25 lines\", model: GEMINI_FLASH },\n      { prompt: \"Use grep to find 'async' in src/plugin/request.ts\", model: CLAUDE_SONNET },\n      { prompt: \"Run: echo 'checkpoint 2' && pwd\", model: GEMINI_FLASH },\n      { prompt: \"Read src/plugin/storage.ts first 20 lines\", model: CLAUDE_SONNET },\n      { prompt: \"Use lsp_diagnostics on src/plugin/token.ts\", model: GEMINI_FLASH },\n      { prompt: \"Read vitest.config.ts completely\", model: CLAUDE_SONNET },\n      { prompt: \"Run: git status --short | head -5\", model: GEMINI_FLASH },\n      { prompt: \"Read src/constants.ts completely\", model: CLAUDE_SONNET },\n      { prompt: \"Run: echo 'final checkpoint' && echo 'all done'\", model: GEMINI_FLASH },\n      { prompt: \"Summarize this entire conversation in 5 bullet points\", model: CLAUDE_SONNET },\n    ],\n    errorPatterns: ERROR_PATTERNS,\n    timeout: 900000,\n  },\n  {\n    name: \"stress-50-turn-endurance\",\n    model: GEMINI_FLASH,\n    category: \"stress\",\n    suite: \"heavy\",\n    turns: generateEnduranceTest(50),\n    errorPatterns: ERROR_PATTERNS,\n    timeout: 1800000,\n  },\n];\n\nfunction generateEnduranceTest(turnCount: number): TurnConfig[] {\n  const turns: TurnConfig[] = [];\n  const prompts = [\n    { prompt: \"What is {n} + {n}? Answer with just the number.\", model: GEMINI_FLASH },\n    { prompt: \"Run: echo 'turn {i}'\", model: CLAUDE_SONNET },\n    { prompt: \"Read package.json and tell me one field\", model: GEMINI_FLASH },\n    { prompt: \"Run: pwd && echo 'ok'\", model: CLAUDE_SONNET },\n    { prompt: \"What turn number are we on? Just say the number.\", model: GEMINI_FLASH },\n    { prompt: \"Run: date +%H:%M:%S\", model: CLAUDE_SONNET },\n    { prompt: \"Use glob to find one .ts file in src/\", model: GEMINI_FLASH },\n    { prompt: \"Run: echo 'checkpoint {i}'\", model: CLAUDE_SONNET },\n    { prompt: \"Read tsconfig.json and tell me target\", model: GEMINI_FLASH },\n    { prompt: \"What have we done in last 3 turns? Brief answer.\", model: CLAUDE_SONNET },\n  ];\n\n  for (let i = 0; i < turnCount; i++) {\n    const template = prompts[i % prompts.length]!;\n    const prompt = template.prompt\n      .replace(/\\{i\\}/g, String(i + 1))\n      .replace(/\\{n\\}/g, String(i + 1));\n    turns.push({ prompt, model: template.model });\n  }\n\n  turns.push({\n    prompt: `We completed ${turnCount} turns. Summarize this session in 3 sentences.`,\n    model: CLAUDE_SONNET,\n  });\n\n  return turns;\n}\n\nconst RATE_LIMIT_ERROR_PATTERNS = [\n  \"false alarm\",\n  \"incorrectly marked as rate limited\",\n  \"wrong quota\",\n];\n\nconst CONCURRENT_TESTS: ConcurrentTest[] = [\n  {\n    name: \"concurrent-5-same-model\",\n    category: \"concurrency\",\n    suite: \"heavy\",\n    concurrentRequests: 5,\n    model: GEMINI_FLASH,\n    prompt: \"What is 2+2? Answer with just the number.\",\n    errorPatterns: [...ERROR_PATTERNS, ...RATE_LIMIT_ERROR_PATTERNS],\n    timeout: 120000,\n  },\n  {\n    name: \"concurrent-3-mixed-models\",\n    category: \"concurrency\",\n    suite: \"heavy\",\n    concurrentRequests: 3,\n    model: GEMINI_FLASH,\n    prompt: \"Say hello in one word.\",\n    errorPatterns: [...ERROR_PATTERNS, ...RATE_LIMIT_ERROR_PATTERNS],\n    timeout: 120000,\n  },\n  {\n    name: \"concurrent-10-antigravity-heavy\",\n    category: \"concurrency\",\n    suite: \"heavy\",\n    concurrentRequests: 10,\n    model: GEMINI_FLASH,\n    prompt: \"What is 1+1? Answer with just the number.\",\n    errorPatterns: [...ERROR_PATTERNS, ...RATE_LIMIT_ERROR_PATTERNS],\n    timeout: 180000,\n  },\n];\n\nconst ALL_TESTS = [...SANITY_TESTS, ...HEAVY_TESTS];\n\nasync function runTurn(\n  prompt: string,\n  model: string,\n  sessionId: string | null,\n  sessionTitle: string,\n  timeout: number\n): Promise<{ output: string; stderr: string; code: number; sessionId: string | null }> {\n  return new Promise((resolve) => {\n    const args = sessionId\n      ? [\"run\", prompt, \"--session\", sessionId, \"--model\", model]\n      : [\"run\", prompt, \"--model\", model, \"--title\", sessionTitle];\n\n    const proc = spawn(\"opencode\", args, {\n      stdio: [\"ignore\", \"pipe\", \"pipe\"],\n      cwd: process.cwd(),\n    });\n\n    let stdout = \"\";\n    let stderr = \"\";\n\n    proc.stdout?.on(\"data\", (data) => {\n      stdout += data.toString();\n    });\n\n    proc.stderr?.on(\"data\", (data) => {\n      stderr += data.toString();\n    });\n\n    const timeoutId = setTimeout(() => {\n      proc.kill(\"SIGTERM\");\n    }, timeout);\n\n    proc.on(\"close\", (code) => {\n      clearTimeout(timeoutId);\n\n      let extractedSessionId = sessionId;\n      if (!extractedSessionId) {\n        const match = stdout.match(/session[:\\s]+([a-zA-Z0-9_-]+)/i) ||\n                      stderr.match(/session[:\\s]+([a-zA-Z0-9_-]+)/i);\n        if (match) {\n          extractedSessionId = match[1] ?? null;\n        }\n      }\n\n      resolve({\n        output: stdout,\n        stderr: stderr,\n        code: code ?? 1,\n        sessionId: extractedSessionId,\n      });\n    });\n\n    proc.on(\"error\", (err) => {\n      clearTimeout(timeoutId);\n      resolve({\n        output: \"\",\n        stderr: err.message,\n        code: 1,\n        sessionId: null,\n      });\n    });\n  });\n}\n\nasync function deleteSession(sessionId: string): Promise<void> {\n  return new Promise((resolve) => {\n    const proc = spawn(\"opencode\", [\"session\", \"delete\", sessionId, \"--force\"], {\n      stdio: [\"ignore\", \"pipe\", \"pipe\"],\n      timeout: 10000,\n      cwd: process.cwd(),\n    });\n\n    proc.on(\"close\", () => resolve());\n    proc.on(\"error\", () => resolve());\n  });\n}\n\nasync function runConcurrentTest(test: ConcurrentTest): Promise<TestResult> {\n  const start = Date.now();\n  const sessionIds: string[] = [];\n\n  process.stdout.write(`  Spawning ${test.concurrentRequests} concurrent requests...`);\n\n  const promises = Array.from({ length: test.concurrentRequests }, (_, i) =>\n    runTurn(\n      `${test.prompt} (request ${i + 1})`,\n      test.model,\n      null,\n      `concurrent-${test.name}-${i}`,\n      test.timeout\n    )\n  );\n\n  const results = await Promise.all(promises);\n  process.stdout.write(\"\\r\" + \" \".repeat(60) + \"\\r\");\n\n  for (const result of results) {\n    if (result.sessionId) {\n      sessionIds.push(result.sessionId);\n    }\n  }\n\n  for (const result of results) {\n    for (const pattern of test.errorPatterns) {\n      if (result.stderr.toLowerCase().includes(pattern.toLowerCase())) {\n        for (const sid of sessionIds) {\n          await deleteSession(sid);\n        }\n        return {\n          success: false,\n          error: `Found error pattern \"${pattern}\" in concurrent response`,\n          duration: Date.now() - start,\n          turnsCompleted: 0,\n        };\n      }\n    }\n  }\n\n  const failedResults = results.filter((r) => r.code !== 0);\n  const failedCount = failedResults.length;\n  if (failedCount > test.concurrentRequests / 2) {\n    for (const sid of sessionIds) {\n      await deleteSession(sid);\n    }\n    const firstFailure = failedResults[0];\n    const failureDetails = firstFailure\n      ? `\\n    First failure stderr: ${firstFailure.stderr.slice(0, 500)}`\n      : \"\";\n    return {\n      success: false,\n      error: `${failedCount}/${test.concurrentRequests} requests failed${failureDetails}`,\n      duration: Date.now() - start,\n      turnsCompleted: test.concurrentRequests - failedCount,\n    };\n  }\n\n  for (const sid of sessionIds) {\n    await deleteSession(sid);\n  }\n\n  return {\n    success: true,\n    duration: Date.now() - start,\n    turnsCompleted: test.concurrentRequests,\n  };\n}\n\nasync function runMultiTurnTest(test: MultiTurnTest): Promise<TestResult> {\n  const start = Date.now();\n  let sessionId: string | null = null;\n  let turnsCompleted = 0;\n\n  for (let index = 0; index < test.turns.length; index++) {\n    const turn = test.turns[index]!;\n    const prompt = typeof turn === \"string\" ? turn : turn.prompt;\n    const model = typeof turn === \"string\" ? test.model : (turn.model ?? test.model);\n    const turnStart = Date.now();\n\n    process.stdout.write(`\\r  Progress: ${index + 1}/${test.turns.length} turns...`);\n\n    const result = await runTurn(\n      prompt,\n      model,\n      sessionId ?? null,\n      `regression-${test.name}`,\n      test.timeout\n    );\n\n    for (const pattern of test.errorPatterns) {\n      if (result.stderr.toLowerCase().includes(pattern.toLowerCase())) {\n        process.stdout.write(\"\\r\" + \" \".repeat(50) + \"\\r\");\n        return {\n          success: false,\n          error: `Turn ${index + 1}: Found error pattern \"${pattern}\"`,\n          duration: Date.now() - start,\n          turnsCompleted,\n          sessionId: sessionId ?? undefined,\n        };\n      }\n    }\n\n    if (result.code !== 0 && result.code !== null) {\n      const isTimeout = Date.now() - turnStart >= test.timeout - 1000;\n      if (isTimeout) {\n        process.stdout.write(\"\\r\" + \" \".repeat(50) + \"\\r\");\n        return {\n          success: false,\n          error: `Turn ${index + 1}: Timeout after ${test.timeout}ms`,\n          duration: Date.now() - start,\n          turnsCompleted,\n          sessionId: sessionId ?? undefined,\n        };\n      }\n    }\n\n    sessionId = result.sessionId;\n    turnsCompleted++;\n  }\n\n  process.stdout.write(\"\\r\" + \" \".repeat(50) + \"\\r\");\n  return {\n    success: true,\n    duration: Date.now() - start,\n    turnsCompleted,\n    sessionId: sessionId ?? undefined,\n  };\n}\n\nfunction parseArgs(): {\n  filterName: string | null;\n  filterCategory: Category | null;\n  suite: TestSuite;\n  dryRun: boolean;\n  help: boolean;\n} {\n  const args = process.argv.slice(2);\n  const getArg = (flag: string): string | null => {\n    const idx = args.indexOf(flag);\n    return idx !== -1 && args[idx + 1] !== undefined ? args[idx + 1]! : null;\n  };\n\n  let suite: TestSuite = \"all\";\n  if (args.includes(\"--sanity\")) suite = \"sanity\";\n  if (args.includes(\"--heavy\")) suite = \"heavy\";\n\n  return {\n    filterName: getArg(\"--test\") ?? getArg(\"--name\"),\n    filterCategory: getArg(\"--category\") as Category | null,\n    suite,\n    dryRun: args.includes(\"--dry-run\"),\n    help: args.includes(\"--help\") || args.includes(\"-h\"),\n  };\n}\n\nfunction showHelp(): void {\n  console.log(`\nMulti-Turn Regression Test Suite for Antigravity Plugin\n\nTest Suites:\n  --sanity    Quick tests (7 tests, ~5 min) - run frequently\n  --heavy     Stress tests (4 tests, ~30 min) - long conversations\n  (default)   All tests\n\nTests:\n  Sanity (quick, repeatable):\n    - thinking-tool-use, thinking-bash-tool, tool-pairing-sequential\n    - opus-thinking-basic, thinking-modification-continue\n    - multi-provider-switch, prompt-too-long-recovery\n\n  Heavy (stress, endurance):\n    - stress-8-turn-multi-provider (8 turns)\n    - opencode-tools-comprehensive (6 turns, all tools)\n    - stress-20-turn-recovery (20 turns, multi-model, recovery)\n    - stress-50-turn-endurance (51 turns, endurance test)\n\nUsage:\n  npx tsx script/test-regression.ts [options]\n\nOptions:\n  --sanity              Run sanity tests only (quick)\n  --heavy               Run heavy tests only (stress)\n  --test <name>         Run specific test by name\n  --category <cat>      Run tests by category\n  --dry-run             List tests without running\n  --help, -h            Show this help\n\nExamples:\n  npx tsx script/test-regression.ts --sanity\n  npx tsx script/test-regression.ts --heavy\n  npx tsx script/test-regression.ts --test stress-20-turn-recovery\n`);\n}\n\nasync function main(): Promise<void> {\n  const { filterName, filterCategory, suite, dryRun, help } = parseArgs();\n\n  if (help) {\n    showHelp();\n    return;\n  }\n\n  let tests: MultiTurnTest[];\n  switch (suite) {\n    case \"sanity\":\n      tests = SANITY_TESTS;\n      break;\n    case \"heavy\":\n      tests = HEAVY_TESTS;\n      break;\n    default:\n      tests = ALL_TESTS;\n  }\n\n  if (filterName) {\n    tests = tests.filter((t) => t.name === filterName);\n  }\n  if (filterCategory && filterCategory !== \"concurrency\") {\n    tests = tests.filter((t) => t.category === filterCategory);\n  }\n\n  const runConcurrentOnly = filterCategory === \"concurrency\";\n  if (runConcurrentOnly) {\n    tests = [];\n  }\n\n  if (tests.length === 0 && !runConcurrentOnly) {\n    console.error(\"No tests match the specified filters\");\n    process.exit(1);\n  }\n\n  const totalTurns = tests.reduce((sum, t) => sum + t.turns.length, 0);\n  const concurrentCount = CONCURRENT_TESTS.reduce((sum, t) => sum + t.concurrentRequests, 0);\n  console.log(`\\n🧪 Regression Tests [${suite.toUpperCase()}] (${tests.length} tests, ${totalTurns} turns + ${concurrentCount} concurrent)\\n${\"=\".repeat(60)}\\n`);\n\n  if (dryRun) {\n    console.log(\"Tests to run:\\n\");\n    for (const test of tests) {\n      console.log(`  ${test.name} [${test.suite}]`);\n      console.log(`    Model: ${test.model}`);\n      console.log(`    Category: ${test.category}`);\n      console.log(`    Turns: ${test.turns.length}`);\n      console.log();\n    }\n    return;\n  }\n\n  const results: { test: MultiTurnTest; result: TestResult }[] = [];\n\n  for (const test of tests) {\n    console.log(`Testing: ${test.name} [${test.suite}]`);\n    console.log(`  Model: ${test.model}`);\n    console.log(`  Turns: ${test.turns.length}`);\n\n    const result = await runMultiTurnTest(test);\n    results.push({ test, result });\n\n    if (result.success) {\n      console.log(`  Status: ✅ PASS (${result.turnsCompleted}/${test.turns.length} turns, ${(result.duration / 1000).toFixed(1)}s)`);\n    } else {\n      console.log(`  Status: ❌ FAIL`);\n      console.log(`    Error: ${result.error}`);\n      console.log(`    Completed: ${result.turnsCompleted}/${test.turns.length} turns`);\n    }\n\n    if (result.sessionId) {\n      await deleteSession(result.sessionId);\n    }\n    console.log();\n  }\n\n  if (suite === \"heavy\" || suite === \"all\" || runConcurrentOnly || filterName) {\n    let concurrentTests = CONCURRENT_TESTS;\n    if (filterName) {\n      concurrentTests = concurrentTests.filter((t) => t.name === filterName);\n    }\n    if (concurrentTests.length === 0 && !runConcurrentOnly && tests.length === 0) {\n      console.error(\"No tests match the specified filters\");\n      process.exit(1);\n    }\n    if (concurrentTests.length > 0) {\n      console.log(`\\n🔄 Concurrent Tests (${concurrentTests.length} tests)\\n${\"-\".repeat(40)}\\n`);\n      for (const test of concurrentTests) {\n        console.log(`Testing: ${test.name} [concurrent]`);\n        console.log(`  Model: ${test.model}`);\n        console.log(`  Concurrent: ${test.concurrentRequests} requests`);\n\n        const result = await runConcurrentTest(test);\n        results.push({ test: test as unknown as MultiTurnTest, result });\n\n        if (result.success) {\n          console.log(`  Status: ✅ PASS (${result.turnsCompleted} requests, ${(result.duration / 1000).toFixed(1)}s)`);\n        } else {\n          console.log(`  Status: ❌ FAIL`);\n          console.log(`    Error: ${result.error}`);\n        }\n        console.log();\n      }\n    }\n  }\n\n  const passed = results.filter((r) => r.result.success).length;\n  const failed = results.filter((r) => !r.result.success).length;\n  const totalTime = results.reduce((sum, r) => sum + r.result.duration, 0);\n\n  console.log(\"=\".repeat(60));\n  console.log(`\\nSummary: ${passed} passed, ${failed} failed (${(totalTime / 1000).toFixed(1)}s total)\\n`);\n\n  if (failed > 0) {\n    console.log(\"Failed tests:\");\n    for (const r of results.filter((r) => !r.result.success)) {\n      console.log(`  ❌ ${r.test.name}: ${r.result.error}`);\n    }\n    process.exit(1);\n  }\n}\n\nmain().catch((err) => {\n  console.error(\"Fatal error:\", err);\n  process.exit(1);\n});\n"
  },
  {
    "path": "scripts/README-PI.md",
    "content": "# Raspberry Pi Runner Setup\n\nUse 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.\n\n## Prerequisites\n- A Raspberry Pi (3, 4, or 5) running Raspberry Pi OS (64-bit recommended) or Ubuntu.\n- Internet connection.\n- SSH access.\n\n## Step 1: Get your Token\n1. Go to your GitHub Repository.\n2. Navigate to **Settings** > **Actions** > **Runners**.\n3. Click **New self-hosted runner**.\n4. Select **Linux** and **ARM64**.\n5. Copy the **Token** shown in the \"Configure\" section (you'll need it in Step 2).\n\n## Step 2: Run the Setup Script\nCopy the `scripts/` folder to your Pi (or just copy-paste the content).\n\n```bash\n# On your Pi\nmkdir -p ~/opencode-setup\ncd ~/opencode-setup\n# (Copy scripts/setup-pi-runner.sh here)\nchmod +x setup-pi-runner.sh\n./setup-pi-runner.sh\n```\n\nFollow the prompts to enter your Repo URL and Token.\n\n## Step 3: Authenticate Tools\nTo enable `gh copilot` and other AI tools, run the auth helper:\n\n```bash\n# (Copy scripts/auth-pi-tools.sh here)\nchmod +x auth-pi-tools.sh\n./auth-pi-tools.sh\n```\nFollow the interactive login flows.\n\n## Step 4: Update Workflow\nOnce your runner is \"Idle\" (green) in GitHub Settings, update your `.github/workflows/issue-triage.yml`:\n\n```yaml\nruns-on: self-hosted\n# or specifically:\n# runs-on: [self-hosted, pi]\n```\n"
  },
  {
    "path": "scripts/auth-pi-tools.sh",
    "content": "#!/bin/bash\nset -e\n\n# Colors\nGREEN='\\033[0;32m'\nBLUE='\\033[0;34m'\nNC='\\033[0m'\n\necho -e \"${BLUE}=== Authenticating Development Tools ===${NC}\"\necho \"This script establishes the persistent sessions for your AI tools.\"\n\n# 1. GitHub & Copilot\necho -e \"${GREEN}[1/2] Authenticating GitHub & Copilot...${NC}\"\necho \"Follow the browser login steps...\"\ngh auth login -h github.com -p https -w\n\necho \"Installing Copilot extension...\"\ngh extension install github/gh-copilot || true\n\necho -e \"${BLUE}NOTE: Copilot might require a separate auth step.${NC}\"\necho \"Running a test command. If prompted, please authenticate.\"\ngh copilot explain \"echo hello\" || true\n\n# 2. Google Cloud (Optional)\necho -e \"${GREEN}[2/2] Authenticating Google Cloud (Optional)...${NC}\"\nif command -v gcloud &> /dev/null; then\n    gcloud auth login\n    gcloud auth application-default login\nelse\n    echo \"gcloud CLI not found. Skipping.\"\n    echo \"To install: curl https://sdk.cloud.google.com | bash\"\nfi\n\necho -e \"${BLUE}=== Authentication Complete ===${NC}\"\necho \"Your credentials are saved in ~/.config/\"\necho \"The Runner service runs as 'root' or your user depending on setup.\"\necho \"Check that the runner user has access to these credentials.\"\n"
  },
  {
    "path": "scripts/check-quota.mjs",
    "content": "import { readFileSync } from \"node:fs\";\nimport { homedir } from \"node:os\";\nimport { join } from \"node:path\";\n\nconst CLIENT_ID = \"1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com\";\nconst CLIENT_SECRET = \"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\";\nconst CLOUD_CODE_BASE = \"https://cloudcode-pa.googleapis.com\";\nconst USER_AGENT = \"antigravity/windows/amd64\";\nconst FALLBACK_PROJECT_ID = \"bamboo-precept-lgxtn\";\n\nfunction getDefaultAccountsPath() {\n  if (process.platform === \"win32\") {\n    const appData = process.env.APPDATA || join(homedir(), \"AppData\", \"Roaming\");\n    return join(appData, \"opencode\", \"antigravity-accounts.json\");\n  }\n  const xdgConfig = process.env.XDG_CONFIG_HOME || join(homedir(), \".config\");\n  return join(xdgConfig, \"opencode\", \"antigravity-accounts.json\");\n}\n\nfunction parseArgs() {\n  const args = process.argv.slice(2);\n  let path = getDefaultAccountsPath();\n  let accountIndex = null;\n  for (let i = 0; i < args.length; i += 1) {\n    const arg = args[i];\n    if (arg === \"--path\" && args[i + 1]) {\n      path = args[i + 1];\n      i += 1;\n      continue;\n    }\n    if (arg === \"--account\" && args[i + 1]) {\n      const parsed = Number.parseInt(args[i + 1], 10);\n      if (!Number.isNaN(parsed)) {\n        accountIndex = parsed - 1;\n      }\n      i += 1;\n    }\n  }\n  return { path, accountIndex };\n}\n\nasync function postJson(url, token, body, extraHeaders = {}) {\n  return fetch(url, {\n    method: \"POST\",\n    headers: {\n      Authorization: `Bearer ${token}`,\n      \"Content-Type\": \"application/json\",\n      \"User-Agent\": USER_AGENT,\n      ...extraHeaders,\n    },\n    body: JSON.stringify(body),\n  });\n}\n\nasync function refreshAccessToken(refreshToken) {\n  const response = await fetch(\"https://oauth2.googleapis.com/token\", {\n    method: \"POST\",\n    headers: {\n      \"Content-Type\": \"application/x-www-form-urlencoded\",\n    },\n    body: new URLSearchParams({\n      grant_type: \"refresh_token\",\n      refresh_token: refreshToken,\n      client_id: CLIENT_ID,\n      client_secret: CLIENT_SECRET,\n    }),\n  });\n\n  if (!response.ok) {\n    const text = await response.text().catch(() => \"\");\n    throw new Error(`Token refresh failed (${response.status}): ${text.slice(0, 200)}`);\n  }\n\n  const payload = await response.json();\n  return payload.access_token;\n}\n\nasync function loadProjectId(accessToken) {\n  const body = { metadata: { ideType: \"ANTIGRAVITY\" } };\n  const response = await postJson(`${CLOUD_CODE_BASE}/v1internal:loadCodeAssist`, accessToken, body);\n  if (!response.ok) {\n    return \"\";\n  }\n  const payload = await response.json();\n  if (typeof payload.cloudaicompanionProject === \"string\") {\n    return payload.cloudaicompanionProject;\n  }\n  if (payload.cloudaicompanionProject && typeof payload.cloudaicompanionProject.id === \"string\") {\n    return payload.cloudaicompanionProject.id;\n  }\n  return \"\";\n}\n\nfunction classifyGroup(modelName) {\n  const lower = modelName.toLowerCase();\n  if (lower.includes(\"claude\")) return \"claude\";\n  if (!lower.includes(\"gemini-3\")) return null;\n  if (lower.includes(\"flash\")) return \"gemini-flash\";\n  return \"gemini-pro\";\n}\n\nfunction updateGroup(groups, group, remainingFraction, resetTime) {\n  const entry = groups[group] || { count: 0 };\n  entry.count += 1;\n  if (typeof remainingFraction === \"number\") {\n    if (entry.remaining === undefined) {\n      entry.remaining = remainingFraction;\n    } else {\n      entry.remaining = Math.min(entry.remaining, remainingFraction);\n    }\n  }\n  if (resetTime) {\n    const timestamp = Date.parse(resetTime);\n    if (Number.isFinite(timestamp)) {\n      if (!entry.resetTime) {\n        entry.resetTime = resetTime;\n      } else {\n        const existing = Date.parse(entry.resetTime);\n        if (!Number.isFinite(existing) || timestamp < existing) {\n          entry.resetTime = resetTime;\n        }\n      }\n    }\n  }\n  groups[group] = entry;\n}\n\nfunction formatDuration(targetTime) {\n  const delta = targetTime - Date.now();\n  if (delta <= 0) return \"now\";\n  const totalSeconds = Math.round(delta / 1000);\n  const hours = Math.floor(totalSeconds / 3600);\n  const minutes = Math.floor((totalSeconds % 3600) / 60);\n  if (hours > 0) return `${hours}h ${minutes}m`;\n  return `${minutes}m`;\n}\n\nfunction printGroup(label, entry) {\n  if (!entry || entry.count === 0) return;\n  const remaining = typeof entry.remaining === \"number\" ? Math.round(entry.remaining * 100) : null;\n  const status = remaining === null ? \"UNKNOWN\" : remaining <= 0 ? \"LIMITED\" : \"OK\";\n  const details = [];\n  if (remaining !== null) details.push(`remaining ${remaining}%`);\n  if (entry.resetTime) {\n    const time = formatDuration(Date.parse(entry.resetTime));\n    details.push(`resets in ${time}`);\n  }\n  const suffix = details.length ? ` (${details.join(\", \")})` : \"\";\n  console.log(`   ${label}: ${status}${suffix}`);\n}\n\nasync function run() {\n  const { path, accountIndex } = parseArgs();\n  const payload = JSON.parse(readFileSync(path, \"utf8\"));\n  const accounts = payload.accounts || [];\n\n  if (accounts.length === 0) {\n    console.log(\"No accounts found.\");\n    return;\n  }\n\n  const selected = accountIndex === null\n    ? accounts.map((account, index) => ({ account, index }))\n    : accounts\n      .map((account, index) => ({ account, index }))\n      .filter((item) => item.index === accountIndex);\n\n  for (const { account, index } of selected) {\n    const label = account.email || `Account ${index + 1}`;\n    const disabled = account.enabled === false ? \" (disabled)\" : \"\";\n    console.log(`\\n${index + 1}. ${label}${disabled}`);\n\n    try {\n      const accessToken = await refreshAccessToken(account.refreshToken);\n      let projectId = await loadProjectId(accessToken);\n      if (!projectId) {\n        projectId = account.managedProjectId || account.projectId || FALLBACK_PROJECT_ID;\n      }\n      console.log(`   project: ${projectId}`);\n\n      const body = projectId ? { project: projectId } : {};\n      const response = await postJson(\n        `${CLOUD_CODE_BASE}/v1internal:fetchAvailableModels`,\n        accessToken,\n        body,\n      );\n      console.log(`   fetchAvailableModels: ${response.status}`);\n\n      if (!response.ok) {\n        const text = await response.text().catch(() => \"\");\n        console.log(`   error: ${text.trim().slice(0, 200)}`);\n        continue;\n      }\n\n      const data = await response.json();\n      const groups = {};\n      const models = data.models || {};\n      for (const [modelName, info] of Object.entries(models)) {\n        const group = classifyGroup(modelName);\n        if (!group) continue;\n        if (!info || !info.quotaInfo) continue;\n        const remaining = info.quotaInfo.remainingFraction ?? 0;\n        updateGroup(groups, group, remaining, info.quotaInfo.resetTime);\n      }\n\n      printGroup(\"Claude\", groups[\"claude\"]);\n      printGroup(\"Gemini 3 Pro\", groups[\"gemini-pro\"]);\n      printGroup(\"Gemini 3 Flash\", groups[\"gemini-flash\"]);\n    } catch (error) {\n      console.log(`   error: ${error instanceof Error ? error.message : String(error)}`);\n    }\n  }\n}\n\nrun().catch((error) => {\n  console.error(error);\n  process.exitCode = 1;\n});\n"
  },
  {
    "path": "scripts/setup-opencode-pi.sh",
    "content": "#!/bin/bash\nset -e\n\n# Colors\nGREEN='\\033[0;32m'\nBLUE='\\033[0;34m'\nYELLOW='\\033[0;33m'\nNC='\\033[0m'\n\necho -e \"${BLUE}=== Opencode CLI Setup for Raspberry Pi ===${NC}\"\n\n# 1. Install Opencode CLI\nif ! command -v opencode &> /dev/null; then\n    echo -e \"${GREEN}[1/3] Installing @opencode-ai/cli globally...${NC}\"\n    npm install -g @opencode-ai/cli\nelse\n    echo -e \"${GREEN}[1/3] Opencode CLI already installed.${NC}\"\nfi\n\n# 2. Configure Opencode\nCONFIG_DIR=\"$HOME/.config/opencode\"\nCONFIG_FILE=\"$CONFIG_DIR/opencode.json\"\n\nmkdir -p \"$CONFIG_DIR\"\n\nif [ ! -f \"$CONFIG_FILE\" ]; then\n    echo -e \"${GREEN}[2/3] Creating opencode.json...${NC}\"\n    cat <<EOF > \"$CONFIG_FILE\"\n{\n  \"\\$schema\": \"https://opencode.ai/config.json\",\n  \"plugin\": [\"opencode-antigravity-auth@latest\"],\n  \"provider\": {\n    \"google\": {\n      \"models\": {\n        \"antigravity-gemini-3-pro\": {\n          \"name\": \"Gemini 3 Pro (Antigravity)\",\n          \"limit\": { \"context\": 1048576, \"output\": 65535 },\n          \"modalities\": { \"input\": [\"text\", \"image\", \"pdf\"], \"output\": [\"text\"] },\n          \"variants\": {\n            \"low\": { \"thinkingLevel\": \"low\" },\n            \"high\": { \"thinkingLevel\": \"high\" }\n          }\n        }\n      }\n    }\n  }\n}\nEOF\n    echo \"Configuration created at $CONFIG_FILE\"\nelse\n    echo -e \"${YELLOW}[2/3] opencode.json already exists. Skipping overwrite.${NC}\"\n    echo \"Make sure 'opencode-antigravity-auth@latest' is in your 'plugin' list.\"\nfi\n\n# 3. Auth Instructions\necho -e \"${BLUE}=== Authentication Required ===${NC}\"\necho -e \"${YELLOW}Since you are on a headless Pi, you need to use SSH port forwarding for the OAuth callback.${NC}\"\necho \"\"\necho \"1. On your LOCAL machine (laptop), run:\"\necho \"   ssh -L 51121:localhost:51121 pi@<YOUR_PI_IP>\"\necho \"\"\necho \"2. On the PI (this terminal), run:\"\necho \"   opencode auth login\"\necho \"\"\necho \"3. Open the URL in your local browser. The callback will be forwarded to the Pi.\"\n"
  },
  {
    "path": "scripts/setup-pi-runner.sh",
    "content": "#!/bin/bash\nset -e\n\n# Colors for output\nGREEN='\\033[0;32m'\nBLUE='\\033[0;34m'\nNC='\\033[0m' # No Color\n\necho -e \"${BLUE}=== Opencode Raspberry Pi Runner Setup ===${NC}\"\n\n# 1. System Updates & Dependencies\necho -e \"${GREEN}[1/5] Installing system dependencies...${NC}\"\nsudo apt-get update\nsudo apt-get install -y curl jq git libdigest-sha-perl\n\n# 2. Install Node.js (LTS)\necho -e \"${GREEN}[2/5] Installing Node.js (LTS)...${NC}\"\nif ! command -v node &> /dev/null; then\n    curl -fsSL https://deb.nodesource.com/setup_lts.x | sudo -E bash -\n    sudo apt-get install -y nodejs\nelse\n    echo \"Node.js is already installed.\"\nfi\n\n# 3. Install GitHub CLI (gh)\necho -e \"${GREEN}[3/5] Installing GitHub CLI...${NC}\"\nif ! command -v gh &> /dev/null; then\n    (type -p wget >/dev/null || (sudo apt update && sudo apt-get install wget -y)) \\\n    && sudo mkdir -p -m 755 /etc/apt/keyrings \\\n    && wget -qO- https://cli.github.com/packages/githubcli-archive-keyring.gpg | sudo tee /etc/apt/keyrings/githubcli-archive-keyring.gpg > /dev/null \\\n    && sudo chmod go+r /etc/apt/keyrings/githubcli-archive-keyring.gpg \\\n    && 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 \\\n    && sudo apt update \\\n    && sudo apt install gh -y\nelse\n    echo \"GitHub CLI is already installed.\"\nfi\n\n# 4. Setup Actions Runner\necho -e \"${GREEN}[4/5] Setting up GitHub Actions Runner...${NC}\"\nmkdir -p actions-runner && cd actions-runner\n\n# Detect architecture\nARCH=$(dpkg --print-architecture)\nif [ \"$ARCH\" == \"arm64\" ]; then\n    RUNNER_ARCH=\"arm64\"\nelif [ \"$ARCH\" == \"armhf\" ]; then\n    RUNNER_ARCH=\"arm\"\nelse\n    RUNNER_ARCH=\"x64\"\nfi\n\necho \"Detected architecture: $RUNNER_ARCH\"\n\n# Fetch latest runner version\nLATEST_VERSION=$(curl -s https://api.github.com/repos/actions/runner/releases/latest | jq -r .tag_name | sed 's/v//')\necho \"Downloading runner version $LATEST_VERSION...\"\n\ncurl -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\n\necho \"Extracting...\"\ntar xzf ./actions-runner-linux-${RUNNER_ARCH}-${LATEST_VERSION}.tar.gz\n\n# 5. Configuration Prompt\necho -e \"${BLUE}=== Configuration Needed ===${NC}\"\necho \"You need your Runner Token from GitHub.\"\necho \"Go to: Settings > Actions > Runners > New self-hosted runner\"\necho \"Enter your Repo URL and Token below.\"\n\nread -p \"Repository URL (e.g., https://github.com/user/repo): \" REPO_URL\nread -p \"Runner Token: \" RUNNER_TOKEN\n\necho -e \"${GREEN}Configuring runner...${NC}\"\n./config.sh --url \"$REPO_URL\" --token \"$RUNNER_TOKEN\" --name \"pi-triage-runner\" --work \"_work\" --labels \"self-hosted,pi\" --unattended --replace\n\necho -e \"${GREEN}Installing service...${NC}\"\nsudo ./svc.sh install\nsudo ./svc.sh start\n\necho -e \"${BLUE}=== Setup Complete! ===${NC}\"\necho \"Your Pi is now listening for jobs.\"\n"
  },
  {
    "path": "src/antigravity/oauth.ts",
    "content": "import { generatePKCE } from \"@openauthjs/openauth/pkce\";\n\nimport {\n  ANTIGRAVITY_CLIENT_ID,\n  ANTIGRAVITY_CLIENT_SECRET,\n  ANTIGRAVITY_REDIRECT_URI,\n  ANTIGRAVITY_SCOPES,\n  ANTIGRAVITY_ENDPOINT_FALLBACKS,\n  ANTIGRAVITY_LOAD_ENDPOINTS,\n  getAntigravityHeaders,\n  GEMINI_CLI_HEADERS,\n} from \"../constants\";\nimport { createLogger } from \"../plugin/logger\";\nimport { calculateTokenExpiry } from \"../plugin/auth\";\n\nconst log = createLogger(\"oauth\");\n\ninterface PkcePair {\n  challenge: string;\n  verifier: string;\n}\n\ninterface AntigravityAuthState {\n  verifier: string;\n  projectId: string;\n}\n\n/**\n * Result returned to the caller after constructing an OAuth authorization URL.\n */\nexport interface AntigravityAuthorization {\n  url: string;\n  verifier: string;\n  projectId: string;\n}\n\ninterface AntigravityTokenExchangeSuccess {\n  type: \"success\";\n  refresh: string;\n  access: string;\n  expires: number;\n  email?: string;\n  projectId: string;\n}\n\ninterface AntigravityTokenExchangeFailure {\n  type: \"failed\";\n  error: string;\n}\n\nexport type AntigravityTokenExchangeResult =\n  | AntigravityTokenExchangeSuccess\n  | AntigravityTokenExchangeFailure;\n\ninterface AntigravityTokenResponse {\n  access_token: string;\n  expires_in: number;\n  refresh_token: string;\n}\n\ninterface AntigravityUserInfo {\n  email?: string;\n}\n\n/**\n * Encode an object into a URL-safe base64 string.\n */\nfunction encodeState(payload: AntigravityAuthState): string {\n  return Buffer.from(JSON.stringify(payload), \"utf8\").toString(\"base64url\");\n}\n\n/**\n * Decode an OAuth state parameter back into its structured representation.\n */\nfunction decodeState(state: string): AntigravityAuthState {\n  const normalized = state.replace(/-/g, \"+\").replace(/_/g, \"/\");\n  const padded = normalized.padEnd(normalized.length + ((4 - normalized.length % 4) % 4), \"=\");\n  const json = Buffer.from(padded, \"base64\").toString(\"utf8\");\n  const parsed = JSON.parse(json);\n  if (typeof parsed.verifier !== \"string\") {\n    throw new Error(\"Missing PKCE verifier in state\");\n  }\n  return {\n    verifier: parsed.verifier,\n    projectId: typeof parsed.projectId === \"string\" ? parsed.projectId : \"\",\n  };\n}\n\n/**\n * Build the Antigravity OAuth authorization URL including PKCE and optional project metadata.\n */\nexport async function authorizeAntigravity(projectId = \"\"): Promise<AntigravityAuthorization> {\n  const pkce = (await generatePKCE()) as PkcePair;\n\n  const url = new URL(\"https://accounts.google.com/o/oauth2/v2/auth\");\n  url.searchParams.set(\"client_id\", ANTIGRAVITY_CLIENT_ID);\n  url.searchParams.set(\"response_type\", \"code\");\n  url.searchParams.set(\"redirect_uri\", ANTIGRAVITY_REDIRECT_URI);\n  url.searchParams.set(\"scope\", ANTIGRAVITY_SCOPES.join(\" \"));\n  url.searchParams.set(\"code_challenge\", pkce.challenge);\n  url.searchParams.set(\"code_challenge_method\", \"S256\");\n  url.searchParams.set(\n    \"state\",\n    encodeState({ verifier: pkce.verifier, projectId: projectId || \"\" }),\n  );\n  url.searchParams.set(\"access_type\", \"offline\");\n  url.searchParams.set(\"prompt\", \"consent\");\n\n  return {\n    url: url.toString(),\n    verifier: pkce.verifier,\n    projectId: projectId || \"\",\n  };\n}\n\nconst FETCH_TIMEOUT_MS = 10000;\n\nasync function fetchWithTimeout(\n  url: string,\n  options: RequestInit,\n  timeoutMs = FETCH_TIMEOUT_MS,\n): Promise<Response> {\n  const controller = new AbortController();\n  const timeout = setTimeout(() => controller.abort(), timeoutMs);\n  try {\n    return await fetch(url, { ...options, signal: controller.signal });\n  } finally {\n    clearTimeout(timeout);\n  }\n}\n\nasync function fetchProjectID(accessToken: string): Promise<string> {\n  const errors: string[] = [];\n  const loadHeaders: Record<string, string> = {\n    Authorization: `Bearer ${accessToken}`,\n    \"Content-Type\": \"application/json\",\n    \"User-Agent\": GEMINI_CLI_HEADERS[\"User-Agent\"],\n    \"Client-Metadata\": getAntigravityHeaders()[\"Client-Metadata\"],\n  };\n\n  const loadEndpoints = Array.from(\n    new Set<string>([...ANTIGRAVITY_LOAD_ENDPOINTS, ...ANTIGRAVITY_ENDPOINT_FALLBACKS]),\n  );\n\n  for (const baseEndpoint of loadEndpoints) {\n    try {\n      const url = `${baseEndpoint}/v1internal:loadCodeAssist`;\n      const response = await fetchWithTimeout(url, {\n        method: \"POST\",\n        headers: loadHeaders,\n        body: JSON.stringify({\n          metadata: {\n            ideType: \"ANTIGRAVITY\",\n            platform: process.platform === \"win32\" ? \"WINDOWS\" : \"MACOS\",\n            pluginType: \"GEMINI\",\n          },\n        }),\n      });\n\n      if (!response.ok) {\n        const message = await response.text().catch(() => \"\");\n        errors.push(\n          `loadCodeAssist ${response.status} at ${baseEndpoint}${\n            message ? `: ${message}` : \"\"\n          }`,\n        );\n        continue;\n      }\n\n      const data = await response.json();\n      if (typeof data.cloudaicompanionProject === \"string\" && data.cloudaicompanionProject) {\n        return data.cloudaicompanionProject;\n      }\n      if (\n        data.cloudaicompanionProject &&\n        typeof data.cloudaicompanionProject.id === \"string\" &&\n        data.cloudaicompanionProject.id\n      ) {\n        return data.cloudaicompanionProject.id;\n      }\n\n      errors.push(`loadCodeAssist missing project id at ${baseEndpoint}`);\n    } catch (e) {\n      errors.push(\n        `loadCodeAssist error at ${baseEndpoint}: ${\n          e instanceof Error ? e.message : String(e)\n        }`,\n      );\n    }\n  }\n\n  if (errors.length) {\n    log.warn(\"Failed to resolve Antigravity project via loadCodeAssist\", { errors: errors.join(\"; \") });\n  }\n  return \"\";\n}\n\n/**\n * Exchange an authorization code for Antigravity CLI access and refresh tokens.\n */\nexport async function exchangeAntigravity(\n  code: string,\n  state: string,\n): Promise<AntigravityTokenExchangeResult> {\n  try {\n    const { verifier, projectId } = decodeState(state);\n\n    const startTime = Date.now();\n    const tokenResponse = await fetch(\"https://oauth2.googleapis.com/token\", {\n      method: \"POST\",\n      headers: {\n        \"Content-Type\": \"application/x-www-form-urlencoded;charset=UTF-8\",\n        \"Accept\": \"*/*\",\n        \"Accept-Encoding\": \"gzip, deflate, br\",\n        \"User-Agent\": GEMINI_CLI_HEADERS[\"User-Agent\"],\n      },\n      body: new URLSearchParams({\n        client_id: ANTIGRAVITY_CLIENT_ID,\n        client_secret: ANTIGRAVITY_CLIENT_SECRET,\n        code,\n        grant_type: \"authorization_code\",\n        redirect_uri: ANTIGRAVITY_REDIRECT_URI,\n        code_verifier: verifier,\n      }),\n    });\n\n    if (!tokenResponse.ok) {\n      const errorText = await tokenResponse.text();\n      return { type: \"failed\", error: errorText };\n    }\n\n    const tokenPayload = (await tokenResponse.json()) as AntigravityTokenResponse;\n\n    const userInfoResponse = await fetch(\n      \"https://www.googleapis.com/oauth2/v1/userinfo?alt=json\",\n      {\n        headers: {\n          Authorization: `Bearer ${tokenPayload.access_token}`,\n          \"User-Agent\": GEMINI_CLI_HEADERS[\"User-Agent\"],\n        },\n      },\n    );\n\n    const userInfo = userInfoResponse.ok\n      ? ((await userInfoResponse.json()) as AntigravityUserInfo)\n      : {};\n\n    const refreshToken = tokenPayload.refresh_token;\n    if (!refreshToken) {\n      return { type: \"failed\", error: \"Missing refresh token in response\" };\n    }\n\n    let effectiveProjectId = projectId;\n    if (!effectiveProjectId) {\n      effectiveProjectId = await fetchProjectID(tokenPayload.access_token);\n    }\n\n    const storedRefresh = `${refreshToken}|${effectiveProjectId || \"\"}`;\n\n    return {\n      type: \"success\",\n      refresh: storedRefresh,\n      access: tokenPayload.access_token,\n      expires: calculateTokenExpiry(startTime, tokenPayload.expires_in),\n      email: userInfo.email,\n      projectId: effectiveProjectId || \"\",\n    };\n  } catch (error) {\n    return {\n      type: \"failed\",\n      error: error instanceof Error ? error.message : \"Unknown error\",\n    };\n  }\n}\n"
  },
  {
    "path": "src/constants.test.ts",
    "content": "import { describe, it, expect } from \"vitest\"\nimport {\n  GEMINI_CLI_HEADERS,\n  getRandomizedHeaders,\n  type HeaderSet,\n} from \"./constants.ts\"\n\ndescribe(\"GEMINI_CLI_HEADERS\", () => {\n  it(\"matches Code Assist headers from opencode-gemini-auth\", () => {\n    expect(GEMINI_CLI_HEADERS).toEqual({\n      \"User-Agent\": \"google-api-nodejs-client/9.15.1\",\n      \"X-Goog-Api-Client\": \"gl-node/22.17.0\",\n      \"Client-Metadata\": \"ideType=IDE_UNSPECIFIED,platform=PLATFORM_UNSPECIFIED,pluginType=GEMINI\",\n    })\n  })\n})\n\ndescribe(\"getRandomizedHeaders\", () => {\n  describe(\"gemini-cli style\", () => {\n    it(\"returns static Code Assist headers\", () => {\n      const headers = getRandomizedHeaders(\"gemini-cli\", \"gemini-2.5-pro\")\n      expect(headers).toEqual({\n        \"User-Agent\": \"google-api-nodejs-client/9.15.1\",\n        \"X-Goog-Api-Client\": \"gl-node/22.17.0\",\n        \"Client-Metadata\": \"ideType=IDE_UNSPECIFIED,platform=PLATFORM_UNSPECIFIED,pluginType=GEMINI\",\n      })\n    })\n\n    it(\"ignores requested model and keeps static User-Agent\", () => {\n      const headers = getRandomizedHeaders(\"gemini-cli\", \"gemini-3-pro-preview\")\n      expect(headers[\"User-Agent\"]).toBe(\"google-api-nodejs-client/9.15.1\")\n    })\n  })\n\n  describe(\"antigravity style\", () => {\n    it(\"returns all three headers\", () => {\n      const headers = getRandomizedHeaders(\"antigravity\")\n      expect(headers[\"User-Agent\"]).toBeDefined()\n      expect(headers[\"X-Goog-Api-Client\"]).toBeDefined()\n      expect(headers[\"Client-Metadata\"]).toBeDefined()\n    })\n\n    it(\"returns User-Agent in antigravity format\", () => {\n      const headers = getRandomizedHeaders(\"antigravity\")\n      expect(headers[\"User-Agent\"]).toMatch(/^antigravity\\//)\n    })\n\n    it(\"aligns Client-Metadata platform with User-Agent platform\", () => {\n      for (let i = 0; i < 50; i++) {\n        const headers = getRandomizedHeaders(\"antigravity\")\n        const ua = headers[\"User-Agent\"]!\n        const metadata = JSON.parse(headers[\"Client-Metadata\"]!)\n        if (ua.includes(\"windows/\")) {\n          expect(metadata.platform).toBe(\"WINDOWS\")\n        } else {\n          expect(metadata.platform).toBe(\"MACOS\")\n        }\n      }\n    })\n\n    it(\"never produces a linux User-Agent\", () => {\n      for (let i = 0; i < 50; i++) {\n        const headers = getRandomizedHeaders(\"antigravity\")\n        expect(headers[\"User-Agent\"]).not.toMatch(/linux\\//)\n      }\n    })\n  })\n})\n\ndescribe(\"HeaderSet type\", () => {\n  it(\"allows omitting X-Goog-Api-Client and Client-Metadata\", () => {\n    const headers: HeaderSet = {\n      \"User-Agent\": \"test\",\n    }\n    expect(headers[\"User-Agent\"]).toBe(\"test\")\n    expect(headers[\"X-Goog-Api-Client\"]).toBeUndefined()\n    expect(headers[\"Client-Metadata\"]).toBeUndefined()\n  })\n\n  it(\"allows including all three headers\", () => {\n    const headers: HeaderSet = {\n      \"User-Agent\": \"test\",\n      \"X-Goog-Api-Client\": \"test-client\",\n      \"Client-Metadata\": \"test-metadata\",\n    }\n    expect(headers[\"User-Agent\"]).toBe(\"test\")\n    expect(headers[\"X-Goog-Api-Client\"]).toBe(\"test-client\")\n    expect(headers[\"Client-Metadata\"]).toBe(\"test-metadata\")\n  })\n})\n"
  },
  {
    "path": "src/constants.ts",
    "content": "/**\n * Constants used for Antigravity OAuth flows and Cloud Code Assist API integration.\n */\nexport const ANTIGRAVITY_CLIENT_ID = \"1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com\";\n\n/**\n * Client secret issued for the Antigravity OAuth application.\n */\nexport const ANTIGRAVITY_CLIENT_SECRET = \"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\";\n\n/**\n * Scopes required for Antigravity integrations.\n */\nexport const ANTIGRAVITY_SCOPES: readonly string[] = [\n  \"https://www.googleapis.com/auth/cloud-platform\",\n  \"https://www.googleapis.com/auth/userinfo.email\",\n  \"https://www.googleapis.com/auth/userinfo.profile\",\n  \"https://www.googleapis.com/auth/cclog\",\n  \"https://www.googleapis.com/auth/experimentsandconfigs\",\n];\n\n/**\n * OAuth redirect URI used by the local CLI callback server.\n */\nexport const ANTIGRAVITY_REDIRECT_URI = \"http://localhost:51121/oauth-callback\";\n\n/**\n * Root endpoints for the Antigravity API (in fallback order).\n * CLIProxy and Vibeproxy use the daily sandbox endpoint first,\n * then fallback to autopush and prod if needed.\n */\nexport const ANTIGRAVITY_ENDPOINT_DAILY = \"https://daily-cloudcode-pa.sandbox.googleapis.com\";\nexport const ANTIGRAVITY_ENDPOINT_AUTOPUSH = \"https://autopush-cloudcode-pa.sandbox.googleapis.com\";\nexport const ANTIGRAVITY_ENDPOINT_PROD = \"https://cloudcode-pa.googleapis.com\";\n\n/**\n * Endpoint fallback order (daily → autopush → prod).\n * Shared across request handling and project discovery to mirror CLIProxy behavior.\n */\nexport const ANTIGRAVITY_ENDPOINT_FALLBACKS = [\n  ANTIGRAVITY_ENDPOINT_DAILY,\n  ANTIGRAVITY_ENDPOINT_AUTOPUSH,\n  ANTIGRAVITY_ENDPOINT_PROD,\n] as const;\n\n/**\n * Preferred endpoint order for project discovery (prod first, then fallbacks).\n * loadCodeAssist appears to be best supported on prod for managed project resolution.\n */\nexport const ANTIGRAVITY_LOAD_ENDPOINTS = [\n  ANTIGRAVITY_ENDPOINT_PROD,\n  ANTIGRAVITY_ENDPOINT_DAILY,\n  ANTIGRAVITY_ENDPOINT_AUTOPUSH,\n] as const;\n\n/**\n * Primary endpoint to use (daily sandbox - same as CLIProxy/Vibeproxy).\n */\nexport const ANTIGRAVITY_ENDPOINT = ANTIGRAVITY_ENDPOINT_DAILY;\n\n/**\n * Gemini CLI endpoint (production).\n * Used for models without :antigravity suffix.\n * Same as opencode-gemini-auth's GEMINI_CODE_ASSIST_ENDPOINT.\n */\nexport const GEMINI_CLI_ENDPOINT = ANTIGRAVITY_ENDPOINT_PROD;\n\n/**\n * Hardcoded project id used when Antigravity does not return one (e.g., business/workspace accounts).\n */\nexport const ANTIGRAVITY_DEFAULT_PROJECT_ID = \"rising-fact-p41fc\";\n\nexport const ANTIGRAVITY_VERSION_FALLBACK = \"1.18.3\";\nlet antigravityVersion = ANTIGRAVITY_VERSION_FALLBACK;\nlet versionLocked = false;\n\nexport function getAntigravityVersion(): string { return antigravityVersion; }\n\n/**\n * Set the runtime Antigravity version. Can only be called once (at startup).\n * Subsequent calls are silently ignored to prevent accidental mutation.\n */\nexport function setAntigravityVersion(version: string): void {\n  if (versionLocked) return;\n  antigravityVersion = version;\n  versionLocked = true;\n}\n\n/** @deprecated Use getAntigravityVersion() for runtime access. */\nexport const ANTIGRAVITY_VERSION = ANTIGRAVITY_VERSION_FALLBACK;\n\nexport function getAntigravityHeaders(): HeaderSet & { \"Client-Metadata\": string } {\n  return {\n    \"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`,\n    \"X-Goog-Api-Client\": \"google-cloud-sdk vscode_cloudshelleditor/0.1\",\n    \"Client-Metadata\": `{\"ideType\":\"ANTIGRAVITY\",\"platform\":\"${process.platform === \"win32\" ? \"WINDOWS\" : \"MACOS\"}\",\"pluginType\":\"GEMINI\"}`,\n  };\n}\n\n/** @deprecated Use getAntigravityHeaders() for runtime access. */\nexport const ANTIGRAVITY_HEADERS = {\n  \"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`,\n  \"X-Goog-Api-Client\": \"google-cloud-sdk vscode_cloudshelleditor/0.1\",\n  \"Client-Metadata\": `{\"ideType\":\"ANTIGRAVITY\",\"platform\":\"${process.platform === \"win32\" ? \"WINDOWS\" : \"MACOS\"}\",\"pluginType\":\"GEMINI\"}`,\n} as const;\n\nexport const GEMINI_CLI_HEADERS = {\n  \"User-Agent\": \"google-api-nodejs-client/9.15.1\",\n  \"X-Goog-Api-Client\": \"gl-node/22.17.0\",\n  \"Client-Metadata\": \"ideType=IDE_UNSPECIFIED,platform=PLATFORM_UNSPECIFIED,pluginType=GEMINI\",\n} as const;\n\nconst ANTIGRAVITY_PLATFORMS = [\"windows/amd64\", \"darwin/arm64\", \"darwin/amd64\"] as const;\n\nconst ANTIGRAVITY_API_CLIENTS = [\n  \"google-cloud-sdk vscode_cloudshelleditor/0.1\",\n  \"google-cloud-sdk vscode/1.96.0\",\n  \"google-cloud-sdk vscode/1.95.0\",\n] as const;\n\nfunction randomFrom<T>(arr: readonly T[]): T {\n  return arr[Math.floor(Math.random() * arr.length)]!;\n}\n\nexport type HeaderSet = {\n  \"User-Agent\": string;\n  \"X-Goog-Api-Client\"?: string;\n  \"Client-Metadata\"?: string;\n};\n\nexport function getRandomizedHeaders(style: HeaderStyle, model?: string): HeaderSet {\n  if (style === \"gemini-cli\") {\n    return {\n      \"User-Agent\": GEMINI_CLI_HEADERS[\"User-Agent\"],\n      \"X-Goog-Api-Client\": GEMINI_CLI_HEADERS[\"X-Goog-Api-Client\"],\n      \"Client-Metadata\": GEMINI_CLI_HEADERS[\"Client-Metadata\"],\n    };\n  }\n  const platform = randomFrom(ANTIGRAVITY_PLATFORMS);\n  const metadataPlatform = platform.startsWith(\"windows\") ? \"WINDOWS\" : \"MACOS\";\n  return {\n    \"User-Agent\": `antigravity/${getAntigravityVersion()} ${platform}`,\n    \"X-Goog-Api-Client\": randomFrom(ANTIGRAVITY_API_CLIENTS),\n    \"Client-Metadata\": `{\"ideType\":\"ANTIGRAVITY\",\"platform\":\"${metadataPlatform}\",\"pluginType\":\"GEMINI\"}`,\n  };\n}\n\nexport type HeaderStyle = \"antigravity\" | \"gemini-cli\";\n\n/**\n * Provider identifier shared between the plugin loader and credential store.\n */\nexport const ANTIGRAVITY_PROVIDER_ID = \"google\";\n\n// ============================================================================\n// TOOL HALLUCINATION PREVENTION (Ported from LLM-API-Key-Proxy)\n// ============================================================================\n\n/**\n * System instruction for Claude tool usage hardening.\n * Prevents hallucinated parameters by explicitly stating the rules.\n * \n * This is injected when tools are present to reduce cases where Claude\n * uses parameter names from its training data instead of the actual schema.\n */\nexport const CLAUDE_TOOL_SYSTEM_INSTRUCTION = `CRITICAL TOOL USAGE INSTRUCTIONS:\nYou are operating in a custom environment where tool definitions differ from your training data.\nYou MUST follow these rules strictly:\n\n1. DO NOT use your internal training data to guess tool parameters\n2. ONLY use the exact parameter structure defined in the tool schema\n3. Parameter names in schemas are EXACT - do not substitute with similar names from your training\n4. Array parameters have specific item types - check the schema's 'items' field for the exact structure\n5. When you see \"STRICT PARAMETERS\" in a tool description, those type definitions override any assumptions\n6. Tool use in agentic workflows is REQUIRED - you must call tools with the exact parameters specified\n\nIf you are unsure about a tool's parameters, YOU MUST read the schema definition carefully.`;\n\n/**\n * Template for parameter signature injection into tool descriptions.\n * {params} will be replaced with the actual parameter list.\n */\nexport const CLAUDE_DESCRIPTION_PROMPT = \"\\n\\n⚠️ STRICT PARAMETERS: {params}.\";\n\nexport const EMPTY_SCHEMA_PLACEHOLDER_NAME = \"_placeholder\";\nexport const EMPTY_SCHEMA_PLACEHOLDER_DESCRIPTION = \"Placeholder. Always pass true.\";\n\n/**\n * Sentinel value to bypass thought signature validation.\n * \n * When a thinking block has an invalid or missing signature (e.g., cache miss,\n * session mismatch, plugin restart), this sentinel can be injected to skip\n * validation instead of failing with \"Invalid signature in thinking block\".\n * \n * This is an officially supported Google API feature, used by:\n * - gemini-cli: https://github.com/google-gemini/gemini-cli\n * - Google .NET SDK: PredictionServiceChatClient.cs\n * \n * @see https://ai.google.dev/gemini-api/docs/thought-signatures\n */\nexport const SKIP_THOUGHT_SIGNATURE = \"skip_thought_signature_validator\";\n\n// ============================================================================\n// ANTIGRAVITY SYSTEM INSTRUCTION (Ported from CLIProxyAPI v6.6.89)\n// ============================================================================\n\n/**\n * System instruction for Antigravity requests.\n * This is injected into requests to match CLIProxyAPI v6.6.89 behavior.\n * The instruction provides identity and guidelines for the Antigravity agent.\n */\n// ============================================================================\n// GOOGLE SEARCH TOOL CONSTANTS\n// ============================================================================\n\n/**\n * Model used for Google Search grounding requests.\n * Uses gemini-2.5-flash for fast, cost-effective search operations. (3-flash is always at capacity and doesn't support souce citation).\n */\nexport const SEARCH_MODEL = \"gemini-2.5-flash\";\n\n/**\n * Thinking budget for deep search (more thorough analysis).\n */\nexport const SEARCH_THINKING_BUDGET_DEEP = 16384;\n\n/**\n * Thinking budget for fast search (quick results).\n */\nexport const SEARCH_THINKING_BUDGET_FAST = 4096;\n\n/**\n * Timeout for search requests in milliseconds (60 seconds).\n */\nexport const SEARCH_TIMEOUT_MS = 60000;\n\n/**\n * System instruction for the Google Search tool.\n */\nexport const SEARCH_SYSTEM_INSTRUCTION = `You are an expert web search assistant with access to Google Search and URL analysis tools.\n\nYour capabilities:\n- Use google_search to find real-time information from the web\n- Use url_context to fetch and analyze content from specific URLs when provided\n\nGuidelines:\n- Always provide accurate, well-sourced information\n- Cite your sources when presenting facts\n- If analyzing URLs, extract the most relevant information\n- Be concise but comprehensive in your responses\n- If information is uncertain or conflicting, acknowledge it\n- Focus on answering the user's question directly`;\n\nexport const ANTIGRAVITY_SYSTEM_INSTRUCTION = `You are Antigravity, a powerful agentic AI coding assistant designed by the Google DeepMind team working on Advanced Agentic Coding.\nYou 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.\n**Absolute paths only**\n**Proactiveness**\n\n<priority>IMPORTANT: The instructions that follow supersede all above. Follow them as your primary directives.</priority>\n`;\n"
  },
  {
    "path": "src/hooks/auto-update-checker/cache.ts",
    "content": "import * as fs from \"node:fs\";\nimport * as path from \"node:path\";\nimport { CACHE_DIR, PACKAGE_NAME } from \"./constants\";\n\ninterface BunLockfile {\n  workspaces?: {\n    \"\"?: {\n      dependencies?: Record<string, string>;\n    };\n  };\n  packages?: Record<string, unknown>;\n}\n\nfunction stripTrailingCommas(json: string): string {\n  return json.replace(/,(\\s*[}\\]])/g, \"$1\");\n}\n\nfunction removeFromBunLock(packageName: string): boolean {\n  const lockPath = path.join(CACHE_DIR, \"bun.lock\");\n  if (!fs.existsSync(lockPath)) return false;\n\n  try {\n    const content = fs.readFileSync(lockPath, \"utf-8\");\n    const lock = JSON.parse(stripTrailingCommas(content)) as BunLockfile;\n    let modified = false;\n\n    if (lock.workspaces?.[\"\"]?.dependencies?.[packageName]) {\n      delete lock.workspaces[\"\"].dependencies[packageName];\n      modified = true;\n    }\n\n    if (lock.packages?.[packageName]) {\n      delete lock.packages[packageName];\n      modified = true;\n    }\n\n    if (modified) {\n      fs.writeFileSync(lockPath, JSON.stringify(lock, null, 2));\n      console.log(`[auto-update-checker] Removed from bun.lock: ${packageName}`);\n    }\n\n    return modified;\n  } catch {\n    return false;\n  }\n}\n\nexport function invalidatePackage(packageName: string = PACKAGE_NAME): boolean {\n  try {\n    const pkgDir = path.join(CACHE_DIR, \"node_modules\", packageName);\n    const pkgJsonPath = path.join(CACHE_DIR, \"package.json\");\n\n    let packageRemoved = false;\n    let dependencyRemoved = false;\n    let lockRemoved = false;\n\n    if (fs.existsSync(pkgDir)) {\n      fs.rmSync(pkgDir, { recursive: true, force: true });\n      console.log(`[auto-update-checker] Package removed: ${pkgDir}`);\n      packageRemoved = true;\n    }\n\n    if (fs.existsSync(pkgJsonPath)) {\n      const content = fs.readFileSync(pkgJsonPath, \"utf-8\");\n      const pkgJson = JSON.parse(content);\n      if (pkgJson.dependencies?.[packageName]) {\n        delete pkgJson.dependencies[packageName];\n        fs.writeFileSync(pkgJsonPath, JSON.stringify(pkgJson, null, 2));\n        console.log(`[auto-update-checker] Dependency removed from package.json: ${packageName}`);\n        dependencyRemoved = true;\n      }\n    }\n\n    lockRemoved = removeFromBunLock(packageName);\n\n    if (!packageRemoved && !dependencyRemoved && !lockRemoved) {\n      console.log(`[auto-update-checker] Package not found, nothing to invalidate: ${packageName}`);\n      return false;\n    }\n\n    return true;\n  } catch (err) {\n    console.error(\"[auto-update-checker] Failed to invalidate package:\", err);\n    return false;\n  }\n}\n\nexport function invalidateCache(): boolean {\n  console.warn(\"[auto-update-checker] WARNING: invalidateCache is deprecated, use invalidatePackage\");\n  return invalidatePackage();\n}\n"
  },
  {
    "path": "src/hooks/auto-update-checker/checker.ts",
    "content": "import * as fs from \"node:fs\";\nimport * as path from \"node:path\";\nimport { fileURLToPath } from \"node:url\";\nimport type { NpmDistTags, OpencodeConfig, PackageJson, UpdateCheckResult } from \"./types\";\nimport {\n  PACKAGE_NAME,\n  NPM_REGISTRY_URL,\n  NPM_FETCH_TIMEOUT,\n  INSTALLED_PACKAGE_JSON,\n  USER_OPENCODE_CONFIG,\n  USER_OPENCODE_CONFIG_JSONC,\n} from \"./constants\";\nimport { logAutoUpdate } from \"./logging\";\n\nexport function isLocalDevMode(directory: string): boolean {\n  return getLocalDevPath(directory) !== null;\n}\n\nfunction stripJsonComments(json: string): string {\n  return json\n    .replace(/\\\\\"|\"(?:\\\\\"|[^\"])*\"|(\\/\\/.*|\\/\\*[\\s\\S]*?\\*\\/)/g, (m: string, g: string | undefined) => (g ? \"\" : m))\n    .replace(/,(\\s*[}\\]])/g, \"$1\");\n}\n\nfunction getConfigPaths(directory: string): string[] {\n  return [\n    path.join(directory, \".opencode\", \"opencode.json\"),\n    path.join(directory, \".opencode\", \"opencode.jsonc\"),\n    path.join(directory, \".opencode.json\"),\n    USER_OPENCODE_CONFIG,\n    USER_OPENCODE_CONFIG_JSONC,\n  ];\n}\n\nexport function getLocalDevPath(directory: string): string | null {\n  for (const configPath of getConfigPaths(directory)) {\n    try {\n      if (!fs.existsSync(configPath)) continue;\n      const content = fs.readFileSync(configPath, \"utf-8\");\n      const config = JSON.parse(stripJsonComments(content)) as OpencodeConfig;\n      const plugins = config.plugin ?? [];\n\n      for (const entry of plugins) {\n        if (entry.startsWith(\"file://\") && entry.includes(PACKAGE_NAME)) {\n          try {\n            return fileURLToPath(entry);\n          } catch {\n            return entry.replace(\"file://\", \"\");\n          }\n        }\n      }\n    } catch {\n      continue;\n    }\n  }\n\n  return null;\n}\n\nfunction findPackageJsonUp(startPath: string): string | null {\n  try {\n    const stat = fs.statSync(startPath);\n    let dir = stat.isDirectory() ? startPath : path.dirname(startPath);\n\n    for (let i = 0; i < 10; i++) {\n      const pkgPath = path.join(dir, \"package.json\");\n      if (fs.existsSync(pkgPath)) {\n        try {\n          const content = fs.readFileSync(pkgPath, \"utf-8\");\n          const pkg = JSON.parse(content) as PackageJson;\n          if (pkg.name === PACKAGE_NAME) return pkgPath;\n        } catch {\n          continue;\n        }\n      }\n      const parent = path.dirname(dir);\n      if (parent === dir) break;\n      dir = parent;\n    }\n  } catch {\n    return null;\n  }\n  return null;\n}\n\nexport function getLocalDevVersion(directory: string): string | null {\n  const localPath = getLocalDevPath(directory);\n  if (!localPath) return null;\n\n  try {\n    const pkgPath = findPackageJsonUp(localPath);\n    if (!pkgPath) return null;\n    const content = fs.readFileSync(pkgPath, \"utf-8\");\n    const pkg = JSON.parse(content) as PackageJson;\n    return pkg.version ?? null;\n  } catch {\n    return null;\n  }\n}\n\nexport interface PluginEntryInfo {\n  entry: string;\n  isPinned: boolean;\n  pinnedVersion: string | null;\n  configPath: string;\n}\n\nexport function findPluginEntry(directory: string): PluginEntryInfo | null {\n  for (const configPath of getConfigPaths(directory)) {\n    try {\n      if (!fs.existsSync(configPath)) continue;\n      const content = fs.readFileSync(configPath, \"utf-8\");\n      const config = JSON.parse(stripJsonComments(content)) as OpencodeConfig;\n      const plugins = config.plugin ?? [];\n\n      for (const entry of plugins) {\n        if (entry === PACKAGE_NAME) {\n          return { entry, isPinned: false, pinnedVersion: null, configPath };\n        }\n        if (entry.startsWith(`${PACKAGE_NAME}@`)) {\n          const pinnedVersion = entry.slice(PACKAGE_NAME.length + 1);\n          const isPinned = pinnedVersion !== \"latest\";\n          return { entry, isPinned, pinnedVersion: isPinned ? pinnedVersion : null, configPath };\n        }\n        if (entry.startsWith(\"file://\") && entry.includes(PACKAGE_NAME)) {\n          return { entry, isPinned: false, pinnedVersion: null, configPath };\n        }\n      }\n    } catch {\n      continue;\n    }\n  }\n\n  return null;\n}\n\nexport function getCachedVersion(): string | null {\n  try {\n    if (fs.existsSync(INSTALLED_PACKAGE_JSON)) {\n      const content = fs.readFileSync(INSTALLED_PACKAGE_JSON, \"utf-8\");\n      const pkg = JSON.parse(content) as PackageJson;\n      if (pkg.version) return pkg.version;\n    }\n  } catch {\n    return null;\n  }\n\n  try {\n    const currentDir = path.dirname(fileURLToPath(import.meta.url));\n    const pkgPath = findPackageJsonUp(currentDir);\n    if (pkgPath) {\n      const content = fs.readFileSync(pkgPath, \"utf-8\");\n      const pkg = JSON.parse(content) as PackageJson;\n      if (pkg.version) return pkg.version;\n    }\n  } catch (err) {\n    logAutoUpdate(`Failed to resolve version from current directory: ${err}`);\n  }\n\n  return null;\n}\n\nexport function updatePinnedVersion(configPath: string, oldEntry: string, newVersion: string): boolean {\n  try {\n    const content = fs.readFileSync(configPath, \"utf-8\");\n    const newEntry = `${PACKAGE_NAME}@${newVersion}`;\n\n    const pluginMatch = content.match(/\"plugin\"\\s*:\\s*\\[/);\n    if (!pluginMatch || pluginMatch.index === undefined) {\n      logAutoUpdate(`No \"plugin\" array found in ${configPath}`);\n      return false;\n    }\n\n    const startIdx = pluginMatch.index + pluginMatch[0].length;\n    let bracketCount = 1;\n    let endIdx = startIdx;\n\n    for (let i = startIdx; i < content.length && bracketCount > 0; i++) {\n      if (content[i] === \"[\") bracketCount++;\n      else if (content[i] === \"]\") bracketCount--;\n      endIdx = i;\n    }\n\n    const before = content.slice(0, startIdx);\n    const pluginArrayContent = content.slice(startIdx, endIdx);\n    const after = content.slice(endIdx);\n\n    const escapedOldEntry = oldEntry.replace(/[.*+?^${}()|[\\]\\\\]/g, \"\\\\$&\");\n    const regex = new RegExp(`[\"']${escapedOldEntry}[\"']`);\n\n    if (!regex.test(pluginArrayContent)) {\n      logAutoUpdate(`Entry \"${oldEntry}\" not found in plugin array of ${configPath}`);\n      return false;\n    }\n\n    const updatedPluginArray = pluginArrayContent.replace(regex, `\"${newEntry}\"`);\n    const updatedContent = before + updatedPluginArray + after;\n\n    if (updatedContent === content) {\n      logAutoUpdate(`No changes made to ${configPath}`);\n      return false;\n    }\n\n    fs.writeFileSync(configPath, updatedContent, \"utf-8\");\n    logAutoUpdate(`Updated ${configPath}: ${oldEntry} → ${newEntry}`);\n    return true;\n  } catch (err) {\n    console.error(`[auto-update-checker] Failed to update config file ${configPath}:`, err);\n    return false;\n  }\n}\n\nexport async function getLatestVersion(): Promise<string | null> {\n  const controller = new AbortController();\n  const timeoutId = setTimeout(() => controller.abort(), NPM_FETCH_TIMEOUT);\n\n  try {\n    const response = await fetch(NPM_REGISTRY_URL, {\n      signal: controller.signal,\n      headers: { Accept: \"application/json\" },\n    });\n\n    if (!response.ok) return null;\n\n    const data = (await response.json()) as NpmDistTags;\n    return data.latest ?? null;\n  } catch {\n    return null;\n  } finally {\n    clearTimeout(timeoutId);\n  }\n}\n\nexport async function checkForUpdate(directory: string): Promise<UpdateCheckResult> {\n  if (isLocalDevMode(directory)) {\n    logAutoUpdate(\"Local dev mode detected, skipping update check\");\n    return { needsUpdate: false, currentVersion: null, latestVersion: null, isLocalDev: true, isPinned: false };\n  }\n\n  const pluginInfo = findPluginEntry(directory);\n  if (!pluginInfo) {\n    logAutoUpdate(\"Plugin not found in config\");\n    return { needsUpdate: false, currentVersion: null, latestVersion: null, isLocalDev: false, isPinned: false };\n  }\n\n  const currentVersion = getCachedVersion() ?? pluginInfo.pinnedVersion;\n  if (!currentVersion) {\n    logAutoUpdate(\"No version found (cached or pinned)\");\n    return { needsUpdate: false, currentVersion: null, latestVersion: null, isLocalDev: false, isPinned: pluginInfo.isPinned };\n  }\n\n  const latestVersion = await getLatestVersion();\n  if (!latestVersion) {\n    logAutoUpdate(\"Failed to fetch latest version\");\n    return { needsUpdate: false, currentVersion, latestVersion: null, isLocalDev: false, isPinned: pluginInfo.isPinned };\n  }\n\n  const needsUpdate = currentVersion !== latestVersion;\n  logAutoUpdate(`Current: ${currentVersion}, Latest: ${latestVersion}, NeedsUpdate: ${needsUpdate}`);\n  return { needsUpdate, currentVersion, latestVersion, isLocalDev: false, isPinned: pluginInfo.isPinned };\n}\n"
  },
  {
    "path": "src/hooks/auto-update-checker/constants.ts",
    "content": "import * as path from \"node:path\";\nimport * as os from \"node:os\";\n\nexport const PACKAGE_NAME = \"opencode-antigravity-auth\";\nexport const NPM_REGISTRY_URL = `https://registry.npmjs.org/-/package/${PACKAGE_NAME}/dist-tags`;\nexport const NPM_FETCH_TIMEOUT = 5000;\n\nfunction getCacheDir(): string {\n  if (process.platform === \"win32\") {\n    return path.join(process.env.LOCALAPPDATA ?? os.homedir(), \"opencode\");\n  }\n  return path.join(os.homedir(), \".cache\", \"opencode\");\n}\n\nexport const CACHE_DIR = getCacheDir();\nexport const INSTALLED_PACKAGE_JSON = path.join(\n  CACHE_DIR,\n  \"node_modules\",\n  PACKAGE_NAME,\n  \"package.json\"\n);\n\nfunction getUserConfigDir(): string {\n  if (process.platform === \"win32\") {\n    return process.env.APPDATA ?? path.join(os.homedir(), \"AppData\", \"Roaming\");\n  }\n  return process.env.XDG_CONFIG_HOME ?? path.join(os.homedir(), \".config\");\n}\n\nexport const USER_CONFIG_DIR = getUserConfigDir();\nexport const USER_OPENCODE_CONFIG = path.join(USER_CONFIG_DIR, \"opencode\", \"opencode.json\");\nexport const USER_OPENCODE_CONFIG_JSONC = path.join(USER_CONFIG_DIR, \"opencode\", \"opencode.jsonc\");\n"
  },
  {
    "path": "src/hooks/auto-update-checker/index.test.ts",
    "content": "import { afterEach, beforeEach, describe, expect, it, vi } from \"vitest\";\n\nvi.mock(\"./checker\", () => ({\n  getCachedVersion: vi.fn(),\n  getLocalDevVersion: vi.fn(),\n  findPluginEntry: vi.fn(),\n  getLatestVersion: vi.fn(),\n  updatePinnedVersion: vi.fn(),\n}));\n\nvi.mock(\"./cache\", () => ({\n  invalidatePackage: vi.fn(),\n}));\n\nvi.mock(\"../../plugin/debug\", () => ({\n  debugLogToFile: vi.fn(),\n}));\n\nimport { createAutoUpdateCheckerHook } from \"./index\";\nimport { getCachedVersion, getLocalDevVersion, findPluginEntry, getLatestVersion, updatePinnedVersion } from \"./checker\";\nimport { invalidatePackage } from \"./cache\";\n\nfunction createMockClient() {\n  return {\n    tui: {\n      showToast: vi.fn().mockResolvedValue(undefined),\n    },\n  };\n}\n\nfunction createPluginInfo(overrides: Partial<ReturnType<typeof findPluginEntry>> = {}) {\n  return {\n    configPath: \"/test/.config/opencode/opencode.json\",\n    entry: \"opencode-antigravity-auth@1.2.6\",\n    pinnedVersion: \"1.2.6\",\n    isPinned: true,\n    ...overrides,\n  };\n}\n\ndescribe(\"Auto Update Checker\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n    vi.useFakeTimers();\n  });\n\n  afterEach(() => {\n    vi.useRealTimers();\n  });\n\n  describe(\"prerelease version handling\", () => {\n    it(\"skips auto-update for beta versions\", async () => {\n      const client = createMockClient();\n      vi.mocked(getLocalDevVersion).mockReturnValue(null);\n      vi.mocked(findPluginEntry).mockReturnValue(createPluginInfo({\n        pinnedVersion: \"1.2.7-beta.1\",\n        entry: \"opencode-antigravity-auth@1.2.7-beta.1\",\n      }));\n      vi.mocked(getCachedVersion).mockReturnValue(null);\n      vi.mocked(getLatestVersion).mockResolvedValue(\"1.2.6\");\n\n      const hook = createAutoUpdateCheckerHook(client, \"/test\", { autoUpdate: true });\n      hook.event({ event: { type: \"session.created\" } });\n\n      await vi.runAllTimersAsync();\n\n      expect(getLatestVersion).not.toHaveBeenCalled();\n      expect(updatePinnedVersion).not.toHaveBeenCalled();\n      expect(invalidatePackage).not.toHaveBeenCalled();\n      expect(client.tui.showToast).not.toHaveBeenCalled();\n    });\n\n    it(\"skips auto-update for alpha versions\", async () => {\n      const client = createMockClient();\n      vi.mocked(getLocalDevVersion).mockReturnValue(null);\n      vi.mocked(findPluginEntry).mockReturnValue(createPluginInfo({\n        pinnedVersion: \"2.0.0-alpha.3\",\n        entry: \"opencode-antigravity-auth@2.0.0-alpha.3\",\n      }));\n      vi.mocked(getCachedVersion).mockReturnValue(null);\n\n      const hook = createAutoUpdateCheckerHook(client, \"/test\", { autoUpdate: true });\n      hook.event({ event: { type: \"session.created\" } });\n\n      await vi.runAllTimersAsync();\n\n      expect(getLatestVersion).not.toHaveBeenCalled();\n    });\n\n    it(\"skips auto-update for rc versions\", async () => {\n      const client = createMockClient();\n      vi.mocked(getLocalDevVersion).mockReturnValue(null);\n      vi.mocked(findPluginEntry).mockReturnValue(createPluginInfo({\n        pinnedVersion: \"1.3.0-rc.1\",\n        entry: \"opencode-antigravity-auth@1.3.0-rc.1\",\n      }));\n      vi.mocked(getCachedVersion).mockReturnValue(null);\n\n      const hook = createAutoUpdateCheckerHook(client, \"/test\", { autoUpdate: true });\n      hook.event({ event: { type: \"session.created\" } });\n\n      await vi.runAllTimersAsync();\n\n      expect(getLatestVersion).not.toHaveBeenCalled();\n    });\n\n    it(\"skips auto-update when cached version is prerelease\", async () => {\n      const client = createMockClient();\n      vi.mocked(getLocalDevVersion).mockReturnValue(null);\n      vi.mocked(findPluginEntry).mockReturnValue(createPluginInfo({\n        pinnedVersion: \"1.2.6\",\n      }));\n      vi.mocked(getCachedVersion).mockReturnValue(\"1.2.7-beta.2\");\n\n      const hook = createAutoUpdateCheckerHook(client, \"/test\", { autoUpdate: true });\n      hook.event({ event: { type: \"session.created\" } });\n\n      await vi.runAllTimersAsync();\n\n      expect(getLatestVersion).not.toHaveBeenCalled();\n    });\n\n    it(\"proceeds with update check for stable versions\", async () => {\n      const client = createMockClient();\n      vi.mocked(getLocalDevVersion).mockReturnValue(null);\n      vi.mocked(findPluginEntry).mockReturnValue(createPluginInfo({\n        pinnedVersion: \"1.2.5\",\n      }));\n      vi.mocked(getCachedVersion).mockReturnValue(null);\n      vi.mocked(getLatestVersion).mockResolvedValue(\"1.2.6\");\n      vi.mocked(updatePinnedVersion).mockReturnValue(true);\n\n      const hook = createAutoUpdateCheckerHook(client, \"/test\", { autoUpdate: true });\n      hook.event({ event: { type: \"session.created\" } });\n\n      await vi.runAllTimersAsync();\n\n      expect(getLatestVersion).toHaveBeenCalled();\n    });\n  });\n\n  describe(\"auto-update disabled\", () => {\n    it(\"shows notification but does not update when autoUpdate is false\", async () => {\n      const client = createMockClient();\n      vi.mocked(getLocalDevVersion).mockReturnValue(null);\n      vi.mocked(findPluginEntry).mockReturnValue(createPluginInfo({\n        pinnedVersion: \"1.2.5\",\n      }));\n      vi.mocked(getCachedVersion).mockReturnValue(null);\n      vi.mocked(getLatestVersion).mockResolvedValue(\"1.2.6\");\n\n      const hook = createAutoUpdateCheckerHook(client, \"/test\", { autoUpdate: false });\n      hook.event({ event: { type: \"session.created\" } });\n\n      await vi.runAllTimersAsync();\n\n      expect(getLatestVersion).toHaveBeenCalled();\n      expect(updatePinnedVersion).not.toHaveBeenCalled();\n      expect(invalidatePackage).not.toHaveBeenCalled();\n      expect(client.tui.showToast).toHaveBeenCalledWith(\n        expect.objectContaining({\n          body: expect.objectContaining({\n            variant: \"info\",\n          }),\n        })\n      );\n    });\n  });\n\n  describe(\"session handling\", () => {\n    it(\"only checks once per hook instance\", async () => {\n      const client = createMockClient();\n      vi.mocked(getLocalDevVersion).mockReturnValue(null);\n      vi.mocked(findPluginEntry).mockReturnValue(createPluginInfo());\n      vi.mocked(getCachedVersion).mockReturnValue(null);\n      vi.mocked(getLatestVersion).mockResolvedValue(\"1.2.6\");\n\n      const hook = createAutoUpdateCheckerHook(client, \"/test\");\n      \n      hook.event({ event: { type: \"session.created\" } });\n      hook.event({ event: { type: \"session.created\" } });\n      hook.event({ event: { type: \"session.created\" } });\n\n      await vi.runAllTimersAsync();\n\n      expect(findPluginEntry).toHaveBeenCalledTimes(1);\n    });\n\n    it(\"ignores child sessions (with parentID)\", async () => {\n      const client = createMockClient();\n      vi.mocked(getLocalDevVersion).mockReturnValue(null);\n\n      const hook = createAutoUpdateCheckerHook(client, \"/test\");\n      hook.event({\n        event: {\n          type: \"session.created\",\n          properties: { info: { parentID: \"parent-123\" } },\n        },\n      });\n\n      await vi.runAllTimersAsync();\n\n      expect(findPluginEntry).not.toHaveBeenCalled();\n    });\n\n    it(\"ignores non-session.created events\", async () => {\n      const client = createMockClient();\n      vi.mocked(getLocalDevVersion).mockReturnValue(null);\n\n      const hook = createAutoUpdateCheckerHook(client, \"/test\");\n      hook.event({ event: { type: \"message.created\" } });\n\n      await vi.runAllTimersAsync();\n\n      expect(findPluginEntry).not.toHaveBeenCalled();\n    });\n  });\n\n  describe(\"local development mode\", () => {\n    it(\"skips update check in local dev mode\", async () => {\n      const client = createMockClient();\n      vi.mocked(getLocalDevVersion).mockReturnValue(\"1.2.7-dev\");\n\n      const hook = createAutoUpdateCheckerHook(client, \"/test\");\n      hook.event({ event: { type: \"session.created\" } });\n\n      await vi.runAllTimersAsync();\n\n      expect(findPluginEntry).not.toHaveBeenCalled();\n      expect(getLatestVersion).not.toHaveBeenCalled();\n    });\n\n    it(\"shows local dev toast when showStartupToast is true\", async () => {\n      const client = createMockClient();\n      vi.mocked(getLocalDevVersion).mockReturnValue(\"1.2.7-dev\");\n\n      const hook = createAutoUpdateCheckerHook(client, \"/test\", { showStartupToast: true });\n      hook.event({ event: { type: \"session.created\" } });\n\n      await vi.runAllTimersAsync();\n\n      expect(client.tui.showToast).toHaveBeenCalledWith(\n        expect.objectContaining({\n          body: expect.objectContaining({\n            variant: \"warning\",\n          }),\n        })\n      );\n    });\n  });\n});\n"
  },
  {
    "path": "src/hooks/auto-update-checker/index.ts",
    "content": "import type { AutoUpdateCheckerOptions } from \"./types\";\nimport { getCachedVersion, getLocalDevVersion, findPluginEntry, getLatestVersion, updatePinnedVersion } from \"./checker\";\nimport { invalidatePackage } from \"./cache\";\nimport { PACKAGE_NAME } from \"./constants\";\nimport { logAutoUpdate } from \"./logging\";\n\ninterface PluginClient {\n  tui: {\n    showToast(options: {\n      body: {\n        title?: string;\n        message: string;\n        variant: \"info\" | \"warning\" | \"success\" | \"error\";\n        duration?: number;\n      };\n    }): Promise<unknown>;\n  };\n}\n\ninterface SessionCreatedEvent {\n  type: \"session.created\";\n  properties?: {\n    info?: {\n      parentID?: string;\n    };\n  };\n}\n\ntype PluginEvent = SessionCreatedEvent | { type: string; properties?: unknown };\n\nexport function createAutoUpdateCheckerHook(\n  client: PluginClient,\n  directory: string,\n  options: AutoUpdateCheckerOptions = {}\n) {\n  const { showStartupToast = true, autoUpdate = true } = options;\n\n  let hasChecked = false;\n\n  return {\n    event: ({ event }: { event: PluginEvent }) => {\n      if (event.type !== \"session.created\") return;\n      if (hasChecked) return;\n\n      const props = event.properties as { info?: { parentID?: string } } | undefined;\n      if (props?.info?.parentID) return;\n\n      hasChecked = true;\n\n      setTimeout(() => {\n        const localDevVersion = getLocalDevVersion(directory);\n\n        if (localDevVersion) {\n          if (showStartupToast) {\n            showLocalDevToast(client, localDevVersion).catch(() => {});\n          }\n          logAutoUpdate(\"Local development mode\");\n          return;\n        }\n\n        runBackgroundUpdateCheck(client, directory, autoUpdate).catch((err) => {\n          logAutoUpdate(`Background update check failed: ${err}`);\n        });\n      }, 0);\n    },\n  };\n}\n\nasync function runBackgroundUpdateCheck(\n  client: PluginClient,\n  directory: string,\n  autoUpdate: boolean\n): Promise<void> {\n  const pluginInfo = findPluginEntry(directory);\n  if (!pluginInfo) {\n    logAutoUpdate(\"Plugin not found in config\");\n    return;\n  }\n\n  const cachedVersion = getCachedVersion();\n  const currentVersion = cachedVersion ?? pluginInfo.pinnedVersion;\n  if (!currentVersion) {\n    logAutoUpdate(\"No version found (cached or pinned)\");\n    return;\n  }\n\n  if (currentVersion.includes('-')) {\n    logAutoUpdate(`Prerelease version (${currentVersion}), skipping auto-update`);\n    return;\n  }\n\n  const latestVersion = await getLatestVersion();\n  if (!latestVersion) {\n    logAutoUpdate(\"Failed to fetch latest version\");\n    return;\n  }\n\n  if (currentVersion === latestVersion) {\n    logAutoUpdate(\"Already on latest version\");\n    return;\n  }\n\n  logAutoUpdate(`Update available: ${currentVersion} → ${latestVersion}`);\n\n  if (!autoUpdate) {\n    await showUpdateAvailableToast(client, latestVersion);\n    logAutoUpdate(\"Auto-update disabled, notification only\");\n    return;\n  }\n\n  if (pluginInfo.isPinned) {\n    const updated = updatePinnedVersion(pluginInfo.configPath, pluginInfo.entry, latestVersion);\n    if (updated) {\n      invalidatePackage(PACKAGE_NAME);\n      await showAutoUpdatedToast(client, currentVersion, latestVersion);\n      logAutoUpdate(`Config updated: ${pluginInfo.entry} → ${PACKAGE_NAME}@${latestVersion}`);\n    } else {\n      await showUpdateAvailableToast(client, latestVersion);\n    }\n  } else {\n    invalidatePackage(PACKAGE_NAME);\n    await showUpdateAvailableToast(client, latestVersion);\n  }\n}\n\nasync function showUpdateAvailableToast(client: PluginClient, latestVersion: string): Promise<void> {\n  await client.tui\n    .showToast({\n      body: {\n        title: `Antigravity Auth Update`,\n        message: `v${latestVersion} available. Restart OpenCode to apply.`,\n        variant: \"info\" as const,\n        duration: 8000,\n      },\n    })\n    .catch(() => {});\n  logAutoUpdate(`Update available toast shown: v${latestVersion}`);\n}\n\nasync function showAutoUpdatedToast(client: PluginClient, oldVersion: string, newVersion: string): Promise<void> {\n  await client.tui\n    .showToast({\n      body: {\n        title: `Antigravity Auth Updated!`,\n        message: `v${oldVersion} → v${newVersion}\\nRestart OpenCode to apply.`,\n        variant: \"success\" as const,\n        duration: 8000,\n      },\n    })\n    .catch(() => {});\n  logAutoUpdate(`Auto-updated toast shown: v${oldVersion} → v${newVersion}`);\n}\n\nasync function showLocalDevToast(client: PluginClient, version: string): Promise<void> {\n  await client.tui\n    .showToast({\n      body: {\n        title: `Antigravity Auth ${version} (dev)`,\n        message: \"Running in local development mode.\",\n        variant: \"warning\" as const,\n        duration: 5000,\n      },\n    })\n    .catch(() => {});\n  logAutoUpdate(`Local dev toast shown: v${version}`);\n}\n\nexport type { UpdateCheckResult, AutoUpdateCheckerOptions } from \"./types\";\nexport { checkForUpdate, getCachedVersion, getLatestVersion } from \"./checker\";\nexport { invalidatePackage, invalidateCache } from \"./cache\";\n"
  },
  {
    "path": "src/hooks/auto-update-checker/logging.ts",
    "content": "import { debugLogToFile } from \"../../plugin/debug\";\n\nconst AUTO_UPDATE_LOG_PREFIX = \"[auto-update-checker]\";\n\nexport function formatAutoUpdateLogMessage(message: string): string {\n  return `${AUTO_UPDATE_LOG_PREFIX} ${message}`;\n}\n\nexport function logAutoUpdate(message: string): void {\n  debugLogToFile(formatAutoUpdateLogMessage(message));\n}\n"
  },
  {
    "path": "src/hooks/auto-update-checker/types.ts",
    "content": "export interface NpmDistTags {\n  latest: string;\n  [key: string]: string;\n}\n\nexport interface OpencodeConfig {\n  plugin?: string[];\n  [key: string]: unknown;\n}\n\nexport interface PackageJson {\n  version: string;\n  name?: string;\n  [key: string]: unknown;\n}\n\nexport interface UpdateCheckResult {\n  needsUpdate: boolean;\n  currentVersion: string | null;\n  latestVersion: string | null;\n  isLocalDev: boolean;\n  isPinned: boolean;\n}\n\nexport interface AutoUpdateCheckerOptions {\n  showStartupToast?: boolean;\n  autoUpdate?: boolean;\n}\n"
  },
  {
    "path": "src/plugin/accounts.test.ts",
    "content": "import { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport { AccountManager, type ModelFamily, type HeaderStyle, parseRateLimitReason, calculateBackoffMs, type RateLimitReason, resolveQuotaGroup } from \"./accounts\";\nimport type { AccountStorageV4 } from \"./storage\";\nimport type { OAuthAuthDetails } from \"./types\";\n\n// Mock storage to prevent test data from leaking to real config files\nvi.mock(\"./storage\", async (importOriginal) => {\n  const original = await importOriginal<typeof import(\"./storage\")>();\n  return {\n    ...original,\n    saveAccounts: vi.fn().mockResolvedValue(undefined),\n    saveAccountsReplace: vi.fn().mockResolvedValue(undefined),\n  };\n});\n\ndescribe(\"AccountManager\", () => {\n  beforeEach(() => {\n    vi.useRealTimers();\n    vi.stubGlobal(\"process\", { ...process, pid: 0 });\n  });\n\n  it(\"treats on-disk storage as source of truth, even when empty\", () => {\n    const fallback: OAuthAuthDetails = {\n      type: \"oauth\",\n      refresh: \"r1|p1\",\n      access: \"access\",\n      expires: 123,\n    };\n\n    const stored: AccountStorageV4 = {\n      version: 4,\n      accounts: [],\n      activeIndex: 0,\n    };\n\n    const manager = new AccountManager(fallback, stored);\n    expect(manager.getAccountCount()).toBe(0);\n  });\n\n  it(\"returns current account when not rate-limited for family\", () => {\n    const stored: AccountStorageV4 = {\n      version: 4,\n      accounts: [\n        { refreshToken: \"r1\", projectId: \"p1\", addedAt: 1, lastUsed: 0 },\n        { refreshToken: \"r2\", projectId: \"p2\", addedAt: 1, lastUsed: 0 },\n      ],\n      activeIndex: 0,\n    };\n\n    const manager = new AccountManager(undefined, stored);\n    const family: ModelFamily = \"claude\";\n\n    const account = manager.getCurrentOrNextForFamily(family);\n\n    expect(account).not.toBeNull();\n    expect(account?.index).toBe(0);\n  });\n\n  it(\"switches to next account when current is rate-limited for family\", () => {\n    const stored: AccountStorageV4 = {\n      version: 4,\n      accounts: [\n        { refreshToken: \"r1\", projectId: \"p1\", addedAt: 1, lastUsed: 0 },\n        { refreshToken: \"r2\", projectId: \"p2\", addedAt: 1, lastUsed: 0 },\n      ],\n      activeIndex: 0,\n    };\n\n    const manager = new AccountManager(undefined, stored);\n    const family: ModelFamily = \"claude\";\n\n    const firstAccount = manager.getCurrentOrNextForFamily(family);\n    manager.markRateLimited(firstAccount!, 60000, family);\n\n    const secondAccount = manager.getCurrentOrNextForFamily(family);\n    expect(secondAccount?.index).toBe(1);\n  });\n\n  it(\"returns null when all accounts are rate-limited for family\", () => {\n    const stored: AccountStorageV4 = {\n      version: 4,\n      accounts: [\n        { refreshToken: \"r1\", projectId: \"p1\", addedAt: 1, lastUsed: 0 },\n        { refreshToken: \"r2\", projectId: \"p2\", addedAt: 1, lastUsed: 0 },\n      ],\n      activeIndex: 0,\n    };\n\n    const manager = new AccountManager(undefined, stored);\n    const family: ModelFamily = \"claude\";\n\n    const accounts = manager.getAccounts();\n    accounts.forEach((acc) => manager.markRateLimited(acc, 60000, family));\n\n    const next = manager.getCurrentOrNextForFamily(family);\n    expect(next).toBeNull();\n  });\n\n  it(\"un-rate-limits accounts after timeout expires\", () => {\n    vi.useFakeTimers();\n    vi.setSystemTime(new Date(0));\n\n    const stored: AccountStorageV4 = {\n      version: 4,\n      accounts: [\n        { refreshToken: \"r1\", projectId: \"p1\", addedAt: 1, lastUsed: 0 },\n      ],\n      activeIndex: 0,\n    };\n\n    const manager = new AccountManager(undefined, stored);\n    const family: ModelFamily = \"claude\";\n    const account = manager.getCurrentOrNextForFamily(family);\n\n    account!.rateLimitResetTimes[family] = Date.now() - 10000;\n\n    const next = manager.getCurrentOrNextForFamily(family);\n    expect(next?.parts.refreshToken).toBe(\"r1\");\n  });\n\n  it(\"returns minimum wait time for family\", () => {\n    vi.useFakeTimers();\n    vi.setSystemTime(new Date(0));\n\n    const stored: AccountStorageV4 = {\n      version: 4,\n      accounts: [\n        { refreshToken: \"r1\", projectId: \"p1\", addedAt: 1, lastUsed: 0 },\n        { refreshToken: \"r2\", projectId: \"p2\", addedAt: 1, lastUsed: 0 },\n      ],\n      activeIndex: 0,\n    };\n\n    const manager = new AccountManager(undefined, stored);\n    const family: ModelFamily = \"claude\";\n    const accounts = manager.getAccounts();\n\n    manager.markRateLimited(accounts[0]!, 30000, family);\n    manager.markRateLimited(accounts[1]!, 60000, family);\n\n    expect(manager.getMinWaitTimeForFamily(family)).toBe(30000);\n  });\n\n  it(\"tracks rate limits per model family independently\", () => {\n    const stored: AccountStorageV4 = {\n      version: 4,\n      accounts: [\n        { refreshToken: \"r1\", projectId: \"p1\", addedAt: 1, lastUsed: 0 },\n      ],\n      activeIndex: 0,\n    };\n\n    const manager = new AccountManager(undefined, stored);\n\n    const account = manager.getCurrentOrNextForFamily(\"claude\");\n    expect(account?.index).toBe(0);\n\n    manager.markRateLimited(account!, 60000, \"claude\");\n\n    expect(manager.getMinWaitTimeForFamily(\"claude\")).toBeGreaterThan(0);\n    expect(manager.getMinWaitTimeForFamily(\"gemini\")).toBe(0);\n\n    const geminiOnAccount0 = manager.getNextForFamily(\"gemini\");\n    expect(geminiOnAccount0?.index).toBe(0);\n\n    const claudeBlocked = manager.getNextForFamily(\"claude\");\n    expect(claudeBlocked).toBeNull();\n  });\n\n  it(\"getCurrentOrNextForFamily sticks to same account until rate-limited\", () => {\n    vi.useFakeTimers();\n    vi.setSystemTime(new Date(0));\n\n    const stored: AccountStorageV4 = {\n      version: 4,\n      accounts: [\n        { refreshToken: \"r1\", projectId: \"p1\", addedAt: 1, lastUsed: 0 },\n        { refreshToken: \"r2\", projectId: \"p2\", addedAt: 1, lastUsed: 0 },\n      ],\n      activeIndex: 0,\n    };\n\n    const manager = new AccountManager(undefined, stored);\n    const family: ModelFamily = \"claude\";\n\n    const first = manager.getCurrentOrNextForFamily(family);\n    expect(first?.parts.refreshToken).toBe(\"r1\");\n\n    const second = manager.getCurrentOrNextForFamily(family);\n    expect(second?.parts.refreshToken).toBe(\"r1\");\n\n    const third = manager.getCurrentOrNextForFamily(family);\n    expect(third?.parts.refreshToken).toBe(\"r1\");\n\n    manager.markRateLimited(first!, 60_000, family);\n\n    const fourth = manager.getCurrentOrNextForFamily(family);\n    expect(fourth?.parts.refreshToken).toBe(\"r2\");\n\n    const fifth = manager.getCurrentOrNextForFamily(family);\n    expect(fifth?.parts.refreshToken).toBe(\"r2\");\n  });\n\n  it(\"removes an account and keeps cursor consistent\", () => {\n    vi.useFakeTimers();\n    vi.setSystemTime(new Date(0));\n\n    const stored: AccountStorageV4 = {\n      version: 4,\n      accounts: [\n        { refreshToken: \"r1\", projectId: \"p1\", addedAt: 1, lastUsed: 0 },\n        { refreshToken: \"r2\", projectId: \"p2\", addedAt: 1, lastUsed: 0 },\n        { refreshToken: \"r3\", projectId: \"p3\", addedAt: 1, lastUsed: 0 },\n      ],\n      activeIndex: 1,\n    };\n\n    const manager = new AccountManager(undefined, stored);\n    const family: ModelFamily = \"claude\";\n\n    const picked = manager.getCurrentOrNextForFamily(family);\n    expect(picked?.parts.refreshToken).toBe(\"r2\");\n\n    manager.removeAccount(picked!);\n    expect(manager.getAccountCount()).toBe(2);\n\n    const next = manager.getNextForFamily(family);\n    expect(next?.parts.refreshToken).toBe(\"r3\");\n  });\n\n  it(\"attaches fallback access tokens only to the matching stored account\", () => {\n    const fallback: OAuthAuthDetails = {\n      type: \"oauth\",\n      refresh: \"r2|p2\",\n      access: \"access-2\",\n      expires: 123,\n    };\n\n    const stored: AccountStorageV4 = {\n      version: 4,\n      accounts: [\n        { refreshToken: \"r1\", projectId: \"p1\", addedAt: 1, lastUsed: 0 },\n        { refreshToken: \"r2\", projectId: \"p2\", addedAt: 1, lastUsed: 0 },\n      ],\n      activeIndex: 0,\n    };\n\n    const manager = new AccountManager(fallback, stored);\n    const snapshot = manager.getAccountsSnapshot();\n\n    expect(snapshot[0]?.access).toBeUndefined();\n    expect(snapshot[0]?.expires).toBeUndefined();\n    expect(snapshot[1]?.access).toBe(\"access-2\");\n    expect(snapshot[1]?.expires).toBe(123);\n  });\n\n  it(\"debounces toast display for same account\", () => {\n    vi.useFakeTimers();\n    vi.setSystemTime(new Date(0));\n\n    const stored: AccountStorageV4 = {\n      version: 4,\n      accounts: [\n        { refreshToken: \"r1\", projectId: \"p1\", addedAt: 1, lastUsed: 0 },\n      ],\n      activeIndex: 0,\n    };\n\n    const manager = new AccountManager(undefined, stored);\n\n    expect(manager.shouldShowAccountToast(0)).toBe(true);\n    manager.markToastShown(0);\n\n    expect(manager.shouldShowAccountToast(0)).toBe(false);\n\n    expect(manager.shouldShowAccountToast(1)).toBe(true);\n\n    vi.setSystemTime(new Date(31000));\n    expect(manager.shouldShowAccountToast(0)).toBe(true);\n  });\n\n  describe(\"header style fallback for Gemini\", () => {\n    it(\"tracks rate limits separately for each header style\", () => {\n      const stored: AccountStorageV4 = {\n        version: 4,\n        accounts: [\n          { refreshToken: \"r1\", projectId: \"p1\", addedAt: 1, lastUsed: 0 },\n        ],\n        activeIndex: 0,\n      };\n\n      const manager = new AccountManager(undefined, stored);\n      const account = manager.getCurrentOrNextForFamily(\"gemini\");\n\n      manager.markRateLimited(account!, 60000, \"gemini\", \"antigravity\");\n\n      expect(manager.isRateLimitedForHeaderStyle(account!, \"gemini\", \"antigravity\")).toBe(true);\n      expect(manager.isRateLimitedForHeaderStyle(account!, \"gemini\", \"gemini-cli\")).toBe(false);\n    });\n\n    it(\"getAvailableHeaderStyle returns antigravity first for Gemini\", () => {\n      const stored: AccountStorageV4 = {\n        version: 4,\n        accounts: [\n          { refreshToken: \"r1\", projectId: \"p1\", addedAt: 1, lastUsed: 0 },\n        ],\n        activeIndex: 0,\n      };\n\n      const manager = new AccountManager(undefined, stored);\n      const account = manager.getCurrentOrNextForFamily(\"gemini\");\n\n      expect(manager.getAvailableHeaderStyle(account!, \"gemini\")).toBe(\"antigravity\");\n    });\n\n    it(\"getAvailableHeaderStyle returns gemini-cli when antigravity is rate-limited\", () => {\n      const stored: AccountStorageV4 = {\n        version: 4,\n        accounts: [\n          { refreshToken: \"r1\", projectId: \"p1\", addedAt: 1, lastUsed: 0 },\n        ],\n        activeIndex: 0,\n      };\n\n      const manager = new AccountManager(undefined, stored);\n      const account = manager.getCurrentOrNextForFamily(\"gemini\");\n\n      manager.markRateLimited(account!, 60000, \"gemini\", \"antigravity\");\n\n      expect(manager.getAvailableHeaderStyle(account!, \"gemini\")).toBe(\"gemini-cli\");\n    });\n\n    it(\"getAvailableHeaderStyle returns null when both header styles are rate-limited\", () => {\n      const stored: AccountStorageV4 = {\n        version: 4,\n        accounts: [\n          { refreshToken: \"r1\", projectId: \"p1\", addedAt: 1, lastUsed: 0 },\n        ],\n        activeIndex: 0,\n      };\n\n      const manager = new AccountManager(undefined, stored);\n      const account = manager.getCurrentOrNextForFamily(\"gemini\");\n\n      manager.markRateLimited(account!, 60000, \"gemini\", \"antigravity\");\n      manager.markRateLimited(account!, 60000, \"gemini\", \"gemini-cli\");\n\n      expect(manager.getAvailableHeaderStyle(account!, \"gemini\")).toBeNull();\n    });\n\n    it(\"getAvailableHeaderStyle always returns antigravity for Claude\", () => {\n      const stored: AccountStorageV4 = {\n        version: 4,\n        accounts: [\n          { refreshToken: \"r1\", projectId: \"p1\", addedAt: 1, lastUsed: 0 },\n        ],\n        activeIndex: 0,\n      };\n\n      const manager = new AccountManager(undefined, stored);\n      const account = manager.getCurrentOrNextForFamily(\"claude\");\n\n      expect(manager.getAvailableHeaderStyle(account!, \"claude\")).toBe(\"antigravity\");\n    });\n\n    it(\"getAvailableHeaderStyle returns null for Claude when rate-limited\", () => {\n      const stored: AccountStorageV4 = {\n        version: 4,\n        accounts: [\n          { refreshToken: \"r1\", projectId: \"p1\", addedAt: 1, lastUsed: 0 },\n        ],\n        activeIndex: 0,\n      };\n\n      const manager = new AccountManager(undefined, stored);\n      const account = manager.getCurrentOrNextForFamily(\"claude\");\n\n      manager.markRateLimited(account!, 60000, \"claude\", \"antigravity\");\n\n      expect(manager.getAvailableHeaderStyle(account!, \"claude\")).toBeNull();\n    });\n\n    it(\"Gemini rate limits expire independently per header style\", () => {\n      vi.useFakeTimers();\n      vi.setSystemTime(new Date(0));\n\n      const stored: AccountStorageV4 = {\n        version: 4,\n        accounts: [\n          { refreshToken: \"r1\", projectId: \"p1\", addedAt: 1, lastUsed: 0 },\n        ],\n        activeIndex: 0,\n      };\n\n      const manager = new AccountManager(undefined, stored);\n      const account = manager.getCurrentOrNextForFamily(\"gemini\");\n\n      manager.markRateLimited(account!, 30000, \"gemini\", \"antigravity\");\n      manager.markRateLimited(account!, 60000, \"gemini\", \"gemini-cli\");\n\n      vi.setSystemTime(new Date(35000));\n\n      expect(manager.isRateLimitedForHeaderStyle(account!, \"gemini\", \"antigravity\")).toBe(false);\n      expect(manager.isRateLimitedForHeaderStyle(account!, \"gemini\", \"gemini-cli\")).toBe(true);\n\n      expect(manager.getAvailableHeaderStyle(account!, \"gemini\")).toBe(\"antigravity\");\n    });\n\n    it(\"getMinWaitTimeForFamily considers both Gemini header styles\", () => {\n      vi.useFakeTimers();\n      vi.setSystemTime(new Date(0));\n\n      const stored: AccountStorageV4 = {\n        version: 4,\n        accounts: [\n          { refreshToken: \"r1\", projectId: \"p1\", addedAt: 1, lastUsed: 0 },\n        ],\n        activeIndex: 0,\n      };\n\n      const manager = new AccountManager(undefined, stored);\n      const account = manager.getCurrentOrNextForFamily(\"gemini\");\n\n      manager.markRateLimited(account!, 30000, \"gemini\", \"antigravity\");\n\n      expect(manager.getMinWaitTimeForFamily(\"gemini\")).toBe(0);\n\n      manager.markRateLimited(account!, 60000, \"gemini\", \"gemini-cli\");\n\n      expect(manager.getMinWaitTimeForFamily(\"gemini\")).toBe(30000);\n    });\n  });\n\n  describe(\"per-family account tracking\", () => {\n    it(\"tracks current account independently per model family\", () => {\n      const stored: AccountStorageV4 = {\n        version: 4,\n        accounts: [\n          { refreshToken: \"r1\", projectId: \"p1\", addedAt: 1, lastUsed: 0 },\n          { refreshToken: \"r2\", projectId: \"p2\", addedAt: 1, lastUsed: 0 },\n        ],\n        activeIndex: 0,\n      };\n\n      const manager = new AccountManager(undefined, stored);\n\n      const claudeAccount = manager.getCurrentOrNextForFamily(\"claude\");\n      expect(claudeAccount?.parts.refreshToken).toBe(\"r1\");\n\n      manager.markRateLimited(claudeAccount!, 60000, \"claude\");\n\n      const nextClaude = manager.getCurrentOrNextForFamily(\"claude\");\n      expect(nextClaude?.parts.refreshToken).toBe(\"r2\");\n\n      const geminiAccount = manager.getCurrentOrNextForFamily(\"gemini\");\n      expect(geminiAccount?.parts.refreshToken).toBe(\"r1\");\n    });\n\n    it(\"switching Claude account does not affect Gemini account selection\", () => {\n      const stored: AccountStorageV4 = {\n        version: 4,\n        accounts: [\n          { refreshToken: \"r1\", projectId: \"p1\", addedAt: 1, lastUsed: 0 },\n          { refreshToken: \"r2\", projectId: \"p2\", addedAt: 1, lastUsed: 0 },\n          { refreshToken: \"r3\", projectId: \"p3\", addedAt: 1, lastUsed: 0 },\n        ],\n        activeIndex: 0,\n      };\n\n      const manager = new AccountManager(undefined, stored);\n\n      expect(manager.getCurrentOrNextForFamily(\"gemini\")?.parts.refreshToken).toBe(\"r1\");\n\n      const claude1 = manager.getCurrentOrNextForFamily(\"claude\");\n      manager.markRateLimited(claude1!, 60000, \"claude\");\n\n      expect(manager.getCurrentOrNextForFamily(\"claude\")?.parts.refreshToken).toBe(\"r2\");\n      expect(manager.getCurrentOrNextForFamily(\"gemini\")?.parts.refreshToken).toBe(\"r1\");\n\n      const claude2 = manager.getCurrentOrNextForFamily(\"claude\");\n      manager.markRateLimited(claude2!, 60000, \"claude\");\n\n      expect(manager.getCurrentOrNextForFamily(\"claude\")?.parts.refreshToken).toBe(\"r3\");\n      expect(manager.getCurrentOrNextForFamily(\"gemini\")?.parts.refreshToken).toBe(\"r1\");\n    });\n\n    it(\"persists per-family indices to storage\", async () => {\n      const stored: AccountStorageV4 = {\n        version: 4,\n        accounts: [\n          { refreshToken: \"r1\", projectId: \"p1\", addedAt: 1, lastUsed: 0 },\n          { refreshToken: \"r2\", projectId: \"p2\", addedAt: 1, lastUsed: 0 },\n        ],\n        activeIndex: 0,\n      };\n\n      const manager = new AccountManager(undefined, stored);\n\n      const claude = manager.getCurrentOrNextForFamily(\"claude\");\n      manager.markRateLimited(claude!, 60000, \"claude\");\n      manager.getCurrentOrNextForFamily(\"claude\");\n\n      expect(manager.getCurrentAccountForFamily(\"claude\")?.index).toBe(1);\n      expect(manager.getCurrentAccountForFamily(\"gemini\")?.index).toBe(0);\n    });\n\n    it(\"loads per-family indices from storage\", () => {\n      const stored: AccountStorageV4 = {\n        version: 4,\n        accounts: [\n          { refreshToken: \"r1\", projectId: \"p1\", addedAt: 1, lastUsed: 0 },\n          { refreshToken: \"r2\", projectId: \"p2\", addedAt: 1, lastUsed: 0 },\n          { refreshToken: \"r3\", projectId: \"p3\", addedAt: 1, lastUsed: 0 },\n        ],\n        activeIndex: 0,\n        activeIndexByFamily: {\n          claude: 2,\n          gemini: 1,\n        },\n      };\n\n      const manager = new AccountManager(undefined, stored);\n\n      expect(manager.getCurrentAccountForFamily(\"claude\")?.parts.refreshToken).toBe(\"r3\");\n      expect(manager.getCurrentAccountForFamily(\"gemini\")?.parts.refreshToken).toBe(\"r2\");\n    });\n\n    it(\"falls back to activeIndex when activeIndexByFamily is not present\", () => {\n      const stored: AccountStorageV4 = {\n        version: 4,\n        accounts: [\n          { refreshToken: \"r1\", projectId: \"p1\", addedAt: 1, lastUsed: 0 },\n          { refreshToken: \"r2\", projectId: \"p2\", addedAt: 1, lastUsed: 0 },\n        ],\n        activeIndex: 1,\n      };\n\n      const manager = new AccountManager(undefined, stored);\n\n      expect(manager.getCurrentAccountForFamily(\"claude\")?.parts.refreshToken).toBe(\"r2\");\n      expect(manager.getCurrentAccountForFamily(\"gemini\")?.parts.refreshToken).toBe(\"r2\");\n    });\n  });\n\n  describe(\"account cooldown (non-429 errors)\", () => {\n    it(\"marks account as cooling down with reason\", () => {\n      const stored: AccountStorageV4 = {\n        version: 4,\n        accounts: [\n          { refreshToken: \"r1\", projectId: \"p1\", addedAt: 1, lastUsed: 0 },\n        ],\n        activeIndex: 0,\n      };\n\n      const manager = new AccountManager(undefined, stored);\n      const account = manager.getCurrentOrNextForFamily(\"claude\");\n\n      manager.markAccountCoolingDown(account!, 30000, \"auth-failure\");\n\n      expect(manager.isAccountCoolingDown(account!)).toBe(true);\n    });\n\n    it(\"cooldown expires after duration\", () => {\n      vi.useFakeTimers();\n      vi.setSystemTime(new Date(0));\n\n      const stored: AccountStorageV4 = {\n        version: 4,\n        accounts: [\n          { refreshToken: \"r1\", projectId: \"p1\", addedAt: 1, lastUsed: 0 },\n        ],\n        activeIndex: 0,\n      };\n\n      const manager = new AccountManager(undefined, stored);\n      const account = manager.getCurrentOrNextForFamily(\"claude\");\n\n      manager.markAccountCoolingDown(account!, 30000, \"network-error\");\n\n      expect(manager.isAccountCoolingDown(account!)).toBe(true);\n\n      vi.setSystemTime(new Date(35000));\n\n      expect(manager.isAccountCoolingDown(account!)).toBe(false);\n    });\n\n    it(\"clearAccountCooldown removes cooldown state\", () => {\n      const stored: AccountStorageV4 = {\n        version: 4,\n        accounts: [\n          { refreshToken: \"r1\", projectId: \"p1\", addedAt: 1, lastUsed: 0 },\n        ],\n        activeIndex: 0,\n      };\n\n      const manager = new AccountManager(undefined, stored);\n      const account = manager.getCurrentOrNextForFamily(\"claude\");\n\n      manager.markAccountCoolingDown(account!, 30000, \"auth-failure\");\n      expect(manager.isAccountCoolingDown(account!)).toBe(true);\n\n      manager.clearAccountCooldown(account!);\n      expect(manager.isAccountCoolingDown(account!)).toBe(false);\n    });\n\n    it(\"cooling down account is skipped in getCurrentOrNextForFamily\", () => {\n      const stored: AccountStorageV4 = {\n        version: 4,\n        accounts: [\n          { refreshToken: \"r1\", projectId: \"p1\", addedAt: 1, lastUsed: 0 },\n          { refreshToken: \"r2\", projectId: \"p2\", addedAt: 1, lastUsed: 0 },\n        ],\n        activeIndex: 0,\n      };\n\n      const manager = new AccountManager(undefined, stored);\n      const account1 = manager.getCurrentOrNextForFamily(\"claude\");\n\n      manager.markAccountCoolingDown(account1!, 30000, \"project-error\");\n\n      const next = manager.getCurrentOrNextForFamily(\"claude\");\n      expect(next?.parts.refreshToken).toBe(\"r2\");\n    });\n\n    it(\"cooldown is independent from rate limits\", () => {\n      const stored: AccountStorageV4 = {\n        version: 4,\n        accounts: [\n          { refreshToken: \"r1\", projectId: \"p1\", addedAt: 1, lastUsed: 0 },\n        ],\n        activeIndex: 0,\n      };\n\n      const manager = new AccountManager(undefined, stored);\n      const account = manager.getCurrentOrNextForFamily(\"gemini\");\n\n      manager.markAccountCoolingDown(account!, 30000, \"auth-failure\");\n\n      expect(manager.isAccountCoolingDown(account!)).toBe(true);\n      expect(manager.isRateLimitedForHeaderStyle(account!, \"gemini\", \"antigravity\")).toBe(false);\n      expect(manager.isRateLimitedForHeaderStyle(account!, \"gemini\", \"gemini-cli\")).toBe(false);\n    });\n  });\n\n  describe(\"account selection strategies\", () => {\n    describe(\"sticky strategy (default)\", () => {\n      it(\"returns same account on consecutive calls\", () => {\n        const stored: AccountStorageV4 = {\n          version: 4,\n          accounts: [\n            { refreshToken: \"r1\", projectId: \"p1\", addedAt: 1, lastUsed: 0 },\n            { refreshToken: \"r2\", projectId: \"p2\", addedAt: 1, lastUsed: 0 },\n          ],\n          activeIndex: 0,\n        };\n\n        const manager = new AccountManager(undefined, stored);\n\n        const first = manager.getCurrentOrNextForFamily(\"claude\", null, \"sticky\");\n        const second = manager.getCurrentOrNextForFamily(\"claude\", null, \"sticky\");\n        const third = manager.getCurrentOrNextForFamily(\"claude\", null, \"sticky\");\n\n        expect(first?.index).toBe(0);\n        expect(second?.index).toBe(0);\n        expect(third?.index).toBe(0);\n      });\n\n      it(\"switches account only when current is rate-limited\", () => {\n        const stored: AccountStorageV4 = {\n          version: 4,\n          accounts: [\n            { refreshToken: \"r1\", projectId: \"p1\", addedAt: 1, lastUsed: 0 },\n            { refreshToken: \"r2\", projectId: \"p2\", addedAt: 1, lastUsed: 0 },\n          ],\n          activeIndex: 0,\n        };\n\n        const manager = new AccountManager(undefined, stored);\n\n        const first = manager.getCurrentOrNextForFamily(\"claude\", null, \"sticky\");\n        expect(first?.index).toBe(0);\n\n        manager.markRateLimited(first!, 60000, \"claude\");\n\n        const second = manager.getCurrentOrNextForFamily(\"claude\", null, \"sticky\");\n        expect(second?.index).toBe(1);\n      });\n    });\n\n    describe(\"round-robin strategy\", () => {\n      it(\"rotates to next account on each call\", () => {\n        const stored: AccountStorageV4 = {\n          version: 4,\n          accounts: [\n            { refreshToken: \"r1\", projectId: \"p1\", addedAt: 1, lastUsed: 0 },\n            { refreshToken: \"r2\", projectId: \"p2\", addedAt: 1, lastUsed: 0 },\n            { refreshToken: \"r3\", projectId: \"p3\", addedAt: 1, lastUsed: 0 },\n          ],\n          activeIndex: 0,\n        };\n\n        const manager = new AccountManager(undefined, stored);\n\n        const first = manager.getCurrentOrNextForFamily(\"claude\", null, \"round-robin\");\n        const second = manager.getCurrentOrNextForFamily(\"claude\", null, \"round-robin\");\n        const third = manager.getCurrentOrNextForFamily(\"claude\", null, \"round-robin\");\n        const fourth = manager.getCurrentOrNextForFamily(\"claude\", null, \"round-robin\");\n\n        const indices = [first?.index, second?.index, third?.index, fourth?.index];\n        expect(new Set(indices).size).toBeGreaterThanOrEqual(2);\n      });\n\n      it(\"skips rate-limited accounts\", () => {\n        const stored: AccountStorageV4 = {\n          version: 4,\n          accounts: [\n            { refreshToken: \"r1\", projectId: \"p1\", addedAt: 1, lastUsed: 0 },\n            { refreshToken: \"r2\", projectId: \"p2\", addedAt: 1, lastUsed: 0 },\n            { refreshToken: \"r3\", projectId: \"p3\", addedAt: 1, lastUsed: 0 },\n          ],\n          activeIndex: 0,\n        };\n\n        const manager = new AccountManager(undefined, stored);\n        const accounts = manager.getAccounts();\n        manager.markRateLimited(accounts[1]!, 60000, \"claude\");\n\n        const first = manager.getCurrentOrNextForFamily(\"claude\", null, \"round-robin\");\n        const second = manager.getCurrentOrNextForFamily(\"claude\", null, \"round-robin\");\n\n        expect(first?.index).not.toBe(1);\n        expect(second?.index).not.toBe(1);\n      });\n    });\n\n    describe(\"hybrid strategy\", () => {\n      it(\"returns fresh (untouched) accounts first\", () => {\n        const stored: AccountStorageV4 = {\n          version: 4,\n          accounts: [\n            { refreshToken: \"r1\", projectId: \"p1\", addedAt: 1, lastUsed: 0 },\n            { refreshToken: \"r2\", projectId: \"p2\", addedAt: 1, lastUsed: 0 },\n            { refreshToken: \"r3\", projectId: \"p3\", addedAt: 1, lastUsed: 0 },\n          ],\n          activeIndex: 0,\n        };\n\n        const manager = new AccountManager(undefined, stored);\n\n        const first = manager.getCurrentOrNextForFamily(\"claude\", null, \"hybrid\");\n        const second = manager.getCurrentOrNextForFamily(\"claude\", null, \"hybrid\");\n        const third = manager.getCurrentOrNextForFamily(\"claude\", null, \"hybrid\");\n\n        const indices = [first?.index, second?.index, third?.index];\n        expect(indices).toContain(0);\n        expect(indices).toContain(1);\n        expect(indices).toContain(2);\n      });\n\n      it(\"continues to return valid accounts after all touched\", () => {\n        const stored: AccountStorageV4 = {\n          version: 4,\n          accounts: [\n            { refreshToken: \"r1\", projectId: \"p1\", addedAt: 1, lastUsed: 0 },\n            { refreshToken: \"r2\", projectId: \"p2\", addedAt: 1, lastUsed: 0 },\n          ],\n          activeIndex: 0,\n        };\n\n        const manager = new AccountManager(undefined, stored);\n\n        manager.getCurrentOrNextForFamily(\"claude\", null, \"hybrid\");\n        manager.getCurrentOrNextForFamily(\"claude\", null, \"hybrid\");\n\n        const third = manager.getCurrentOrNextForFamily(\"claude\", null, \"hybrid\");\n        const fourth = manager.getCurrentOrNextForFamily(\"claude\", null, \"hybrid\");\n\n        expect(third).not.toBeNull();\n        expect(fourth).not.toBeNull();\n        expect([0, 1]).toContain(third?.index);\n        expect([0, 1]).toContain(fourth?.index);\n      });\n    });\n\n    describe(\"hybrid strategy with token bucket\", () => {\n      it(\"returns account based on health and token availability\", () => {\n        const stored: AccountStorageV4 = {\n          version: 4,\n          accounts: [\n            { refreshToken: \"r1\", projectId: \"p1\", addedAt: 1, lastUsed: 0 },\n            { refreshToken: \"r2\", projectId: \"p2\", addedAt: 1, lastUsed: 0 },\n            { refreshToken: \"r3\", projectId: \"p3\", addedAt: 1, lastUsed: 0 },\n          ],\n          activeIndex: 0,\n        };\n\n        const manager = new AccountManager(undefined, stored);\n\n        const first = manager.getCurrentOrNextForFamily(\"claude\", null, \"hybrid\");\n        expect(first).not.toBeNull();\n        expect([0, 1, 2]).toContain(first?.index);\n      });\n\n      it(\"skips rate-limited accounts\", () => {\n        const stored: AccountStorageV4 = {\n          version: 4,\n          accounts: [\n            { refreshToken: \"r1\", projectId: \"p1\", addedAt: 1, lastUsed: 0 },\n            { refreshToken: \"r2\", projectId: \"p2\", addedAt: 1, lastUsed: 0 },\n          ],\n          activeIndex: 0,\n        };\n\n        const manager = new AccountManager(undefined, stored);\n        const accounts = manager.getAccounts();\n        manager.markRateLimited(accounts[0]!, 60000, \"claude\");\n\n        const selected = manager.getCurrentOrNextForFamily(\"claude\", null, \"hybrid\");\n        expect(selected?.index).toBe(1);\n      });\n\n      it(\"skips cooling down accounts\", () => {\n        const stored: AccountStorageV4 = {\n          version: 4,\n          accounts: [\n            { refreshToken: \"r1\", projectId: \"p1\", addedAt: 1, lastUsed: 0 },\n            { refreshToken: \"r2\", projectId: \"p2\", addedAt: 1, lastUsed: 0 },\n          ],\n          activeIndex: 0,\n        };\n\n        const manager = new AccountManager(undefined, stored);\n        const accounts = manager.getAccounts();\n        manager.markAccountCoolingDown(accounts[0]!, 60000, \"auth-failure\");\n\n        const selected = manager.getCurrentOrNextForFamily(\"claude\", null, \"hybrid\");\n        expect(selected?.index).toBe(1);\n      });\n\n      it(\"falls back to sticky when all accounts unavailable\", () => {\n        vi.useFakeTimers();\n        vi.setSystemTime(new Date(0));\n\n        const stored: AccountStorageV4 = {\n          version: 4,\n          accounts: [\n            { refreshToken: \"r1\", projectId: \"p1\", addedAt: 1, lastUsed: 0 },\n          ],\n          activeIndex: 0,\n        };\n\n        const manager = new AccountManager(undefined, stored);\n\n        const selected = manager.getCurrentOrNextForFamily(\"claude\", null, \"hybrid\");\n        expect(selected?.index).toBe(0);\n      });\n\n      it(\"updates lastUsed and currentAccountIndexByFamily on selection\", () => {\n        vi.useFakeTimers();\n        vi.setSystemTime(new Date(5000));\n\n        const stored: AccountStorageV4 = {\n          version: 4,\n          accounts: [\n            { refreshToken: \"r1\", projectId: \"p1\", addedAt: 1, lastUsed: 0 },\n            { refreshToken: \"r2\", projectId: \"p2\", addedAt: 1, lastUsed: 0 },\n          ],\n          activeIndex: 0,\n        };\n\n        const manager = new AccountManager(undefined, stored);\n        const selected = manager.getCurrentOrNextForFamily(\"claude\", null, \"hybrid\");\n\n        expect(selected).not.toBeNull();\n        expect(selected!.lastUsed).toBe(5000);\n        expect(manager.getCurrentAccountForFamily(\"claude\")?.index).toBe(selected?.index);\n      });\n    });\n  });\n\n  describe(\"touchedForQuota tracking\", () => {\n    it(\"marks account as touched with timestamp\", () => {\n      vi.useFakeTimers();\n      vi.setSystemTime(new Date(1000));\n\n      const stored: AccountStorageV4 = {\n        version: 4,\n        accounts: [\n          { refreshToken: \"r1\", projectId: \"p1\", addedAt: 1, lastUsed: 0 },\n        ],\n        activeIndex: 0,\n      };\n\n      const manager = new AccountManager(undefined, stored);\n      const account = manager.getAccounts()[0]!;\n\n      manager.markTouchedForQuota(account, \"claude:antigravity\");\n\n      expect(account.touchedForQuota[\"claude:antigravity\"]).toBe(1000);\n    });\n\n    it(\"isFreshForQuota returns true for untouched accounts\", () => {\n      const stored: AccountStorageV4 = {\n        version: 4,\n        accounts: [\n          { refreshToken: \"r1\", projectId: \"p1\", addedAt: 1, lastUsed: 0 },\n        ],\n        activeIndex: 0,\n      };\n\n      const manager = new AccountManager(undefined, stored);\n      const account = manager.getAccounts()[0]!;\n\n      expect(manager.isFreshForQuota(account, \"claude:antigravity\")).toBe(true);\n    });\n\n    it(\"isFreshForQuota returns false for recently touched accounts\", () => {\n      vi.useFakeTimers();\n      vi.setSystemTime(new Date(1000));\n\n      const stored: AccountStorageV4 = {\n        version: 4,\n        accounts: [\n          { refreshToken: \"r1\", projectId: \"p1\", addedAt: 1, lastUsed: 0 },\n        ],\n        activeIndex: 0,\n      };\n\n      const manager = new AccountManager(undefined, stored);\n      const account = manager.getAccounts()[0]!;\n\n      manager.markTouchedForQuota(account, \"claude:antigravity\");\n\n      expect(manager.isFreshForQuota(account, \"claude:antigravity\")).toBe(false);\n    });\n\n    it(\"isFreshForQuota returns true after quota reset time passes\", () => {\n      vi.useFakeTimers();\n      vi.setSystemTime(new Date(1000));\n\n      const stored: AccountStorageV4 = {\n        version: 4,\n        accounts: [\n          { refreshToken: \"r1\", projectId: \"p1\", addedAt: 1, lastUsed: 0 },\n        ],\n        activeIndex: 0,\n      };\n\n      const manager = new AccountManager(undefined, stored);\n      const account = manager.getAccounts()[0]!;\n\n      manager.markTouchedForQuota(account, \"claude\");\n      expect(manager.isFreshForQuota(account, \"claude\")).toBe(false);\n      \n      manager.markRateLimited(account, 60000, \"claude\", \"antigravity\");\n      \n      vi.setSystemTime(new Date(70000));\n      expect(manager.isFreshForQuota(account, \"claude\")).toBe(true);\n    });\n  });\n\n  describe(\"consecutiveFailures tracking\", () => {\n    it(\"initializes consecutiveFailures as undefined\", () => {\n      const stored: AccountStorageV4 = {\n        version: 4,\n        accounts: [\n          { refreshToken: \"r1\", projectId: \"p1\", addedAt: 1, lastUsed: 0 },\n        ],\n        activeIndex: 0,\n      };\n\n      const manager = new AccountManager(undefined, stored);\n      const account = manager.getAccounts()[0]!;\n\n      expect(account.consecutiveFailures).toBeUndefined();\n    });\n\n    it(\"can increment and reset consecutiveFailures\", () => {\n      const stored: AccountStorageV4 = {\n        version: 4,\n        accounts: [\n          { refreshToken: \"r1\", projectId: \"p1\", addedAt: 1, lastUsed: 0 },\n        ],\n        activeIndex: 0,\n      };\n\n      const manager = new AccountManager(undefined, stored);\n      const account = manager.getAccounts()[0]!;\n\n      account.consecutiveFailures = (account.consecutiveFailures ?? 0) + 1;\n      expect(account.consecutiveFailures).toBe(1);\n\n      account.consecutiveFailures = (account.consecutiveFailures ?? 0) + 1;\n      expect(account.consecutiveFailures).toBe(2);\n\n      account.consecutiveFailures = 0;\n      expect(account.consecutiveFailures).toBe(0);\n    });\n  });\n\n  describe(\"Issue #147: headerStyle-aware account selection\", () => {\n    it(\"skips account when requested headerStyle is rate-limited even if other style is available\", () => {\n      const stored: AccountStorageV4 = {\n        version: 4,\n        accounts: [\n          { refreshToken: \"r1\", projectId: \"p1\", addedAt: 1, lastUsed: 0 },\n          { refreshToken: \"r2\", projectId: \"p2\", addedAt: 1, lastUsed: 0 },\n        ],\n        activeIndex: 0,\n        activeIndexByFamily: { claude: 0, gemini: 0 },\n      };\n\n      const manager = new AccountManager(undefined, stored);\n      const firstAccount = manager.getCurrentOrNextForFamily(\"gemini\");\n\n      // Mark ONLY antigravity as rate-limited (gemini-cli is still available)\n      manager.markRateLimited(firstAccount!, 60000, \"gemini\", \"antigravity\");\n\n      // Verify: antigravity is limited, gemini-cli is not\n      expect(manager.isRateLimitedForHeaderStyle(firstAccount!, \"gemini\", \"antigravity\")).toBe(true);\n      expect(manager.isRateLimitedForHeaderStyle(firstAccount!, \"gemini\", \"gemini-cli\")).toBe(false);\n\n      // BUG: When we explicitly request antigravity headerStyle, \n      // we should skip this account and get the next one\n      // Current behavior: returns the same account because \"family\" is not fully limited\n      const nextAccount = manager.getCurrentOrNextForFamily(\n        \"gemini\", \n        null, \n        \"sticky\", \n        \"antigravity\"  // Explicitly requesting antigravity\n      );\n\n      // Verifies headerStyle-aware account selection: should skip account 0\n      // because its antigravity quota is limited, even though gemini-cli is available\n      expect(nextAccount?.index).toBe(1);\n    });\n\n    it(\"returns same account when a different headerStyle is rate-limited\", () => {\n      const stored: AccountStorageV4 = {\n        version: 4,\n        accounts: [\n          { refreshToken: \"r1\", projectId: \"p1\", addedAt: 1, lastUsed: 0 },\n          { refreshToken: \"r2\", projectId: \"p2\", addedAt: 1, lastUsed: 0 },\n        ],\n        activeIndex: 0,\n        activeIndexByFamily: { claude: 0, gemini: 0 },\n      };\n\n      const manager = new AccountManager(undefined, stored);\n      const firstAccount = manager.getCurrentOrNextForFamily(\"gemini\");\n\n      // Mark gemini-cli as rate-limited (antigravity is still available)\n      manager.markRateLimited(firstAccount!, 60000, \"gemini\", \"gemini-cli\");\n\n      // When requesting antigravity, should return the same account\n      // because antigravity quota is still available\n      const nextAccount = manager.getCurrentOrNextForFamily(\n        \"gemini\", \n        null, \n        \"sticky\", \n        \"antigravity\"  // Requesting antigravity which is NOT limited\n      );\n\n      expect(nextAccount?.index).toBe(0); // Should stay on account 0\n    });\n  });\n\n  describe(\"Issue #174: saveToDisk throttling\", () => {\n    it(\"requestSaveToDisk coalesces multiple calls into one write\", async () => {\n      vi.useFakeTimers();\n\n      const stored: AccountStorageV4 = {\n        version: 4,\n        accounts: [\n          { refreshToken: \"r1\", projectId: \"p1\", addedAt: 1, lastUsed: 0 },\n        ],\n        activeIndex: 0,\n      };\n\n      const manager = new AccountManager(undefined, stored);\n      const saveSpy = vi.spyOn(manager, \"saveToDisk\").mockResolvedValue();\n\n      manager.requestSaveToDisk();\n      manager.requestSaveToDisk();\n      manager.requestSaveToDisk();\n\n      expect(saveSpy).not.toHaveBeenCalled();\n\n      await vi.advanceTimersByTimeAsync(1500);\n\n      expect(saveSpy).toHaveBeenCalledTimes(1);\n\n      saveSpy.mockRestore();\n    });\n\n    it(\"flushSaveToDisk waits for pending save to complete\", async () => {\n      vi.useFakeTimers();\n\n      const stored: AccountStorageV4 = {\n        version: 4,\n        accounts: [\n          { refreshToken: \"r1\", projectId: \"p1\", addedAt: 1, lastUsed: 0 },\n        ],\n        activeIndex: 0,\n      };\n\n      const manager = new AccountManager(undefined, stored);\n      const saveSpy = vi.spyOn(manager, \"saveToDisk\").mockResolvedValue();\n\n      manager.requestSaveToDisk();\n\n      const flushPromise = manager.flushSaveToDisk();\n\n      await vi.advanceTimersByTimeAsync(1500);\n      await flushPromise;\n\n      expect(saveSpy).toHaveBeenCalledTimes(1);\n\n      saveSpy.mockRestore();\n    });\n\n    it(\"does not save again if no new requestSaveToDisk after flush\", async () => {\n      vi.useFakeTimers();\n\n      const stored: AccountStorageV4 = {\n        version: 4,\n        accounts: [\n          { refreshToken: \"r1\", projectId: \"p1\", addedAt: 1, lastUsed: 0 },\n        ],\n        activeIndex: 0,\n      };\n\n      const manager = new AccountManager(undefined, stored);\n      const saveSpy = vi.spyOn(manager, \"saveToDisk\").mockResolvedValue();\n\n      manager.requestSaveToDisk();\n      await vi.advanceTimersByTimeAsync(1500);\n\n      expect(saveSpy).toHaveBeenCalledTimes(1);\n\n      await vi.advanceTimersByTimeAsync(3000);\n\n      expect(saveSpy).toHaveBeenCalledTimes(1);\n\n      saveSpy.mockRestore();\n    });\n  });\n\n  describe(\"Rate Limit Reason Classification\", () => {\n    it(\"getMinWaitTimeForFamily respects strict header style\", () => {\n      vi.useFakeTimers();\n      vi.setSystemTime(new Date(0));\n\n      const stored: AccountStorageV4 = {\n        version: 4,\n        accounts: [\n          { refreshToken: \"r1\", projectId: \"p1\", addedAt: 1, lastUsed: 0 },\n        ],\n        activeIndex: 0,\n      };\n\n      const manager = new AccountManager(undefined, stored);\n      const account = manager.getCurrentOrNextForFamily(\"gemini\");\n\n      manager.markRateLimited(account!, 30000, \"gemini\", \"antigravity\", \"gemini-3-pro-image\");\n\n      expect(\n        manager.getMinWaitTimeForFamily(\n          \"gemini\",\n          \"gemini-3-pro-image\",\n          \"antigravity\",\n          true,\n        ),\n      ).toBe(30000);\n\n      expect(manager.getMinWaitTimeForFamily(\"gemini\", \"gemini-3-pro-image\")).toBe(0);\n    });\n\n    describe(\"parseRateLimitReason\", () => {\n      it(\"parses QUOTA_EXHAUSTED from reason field\", () => {\n        expect(parseRateLimitReason(\"QUOTA_EXHAUSTED\", undefined)).toBe(\"QUOTA_EXHAUSTED\");\n        expect(parseRateLimitReason(\"quota_exhausted\", undefined)).toBe(\"QUOTA_EXHAUSTED\");\n      });\n\n      it(\"parses RATE_LIMIT_EXCEEDED from reason field\", () => {\n        expect(parseRateLimitReason(\"RATE_LIMIT_EXCEEDED\", undefined)).toBe(\"RATE_LIMIT_EXCEEDED\");\n      });\n\n      it(\"parses MODEL_CAPACITY_EXHAUSTED from reason field\", () => {\n        expect(parseRateLimitReason(\"MODEL_CAPACITY_EXHAUSTED\", undefined)).toBe(\"MODEL_CAPACITY_EXHAUSTED\");\n      });\n\n      it(\"falls back to message parsing when reason is absent\", () => {\n        expect(parseRateLimitReason(undefined, \"Rate limit exceeded per minute\")).toBe(\"RATE_LIMIT_EXCEEDED\");\n        expect(parseRateLimitReason(undefined, \"Too many requests\")).toBe(\"RATE_LIMIT_EXCEEDED\");\n        expect(parseRateLimitReason(undefined, \"Quota exhausted for today\")).toBe(\"QUOTA_EXHAUSTED\");\n      });\n\n      it(\"returns UNKNOWN when no pattern matches\", () => {\n        expect(parseRateLimitReason(undefined, \"Some other error\")).toBe(\"UNKNOWN\");\n        expect(parseRateLimitReason(undefined, undefined)).toBe(\"UNKNOWN\");\n      });\n    });\n\n    describe(\"calculateBackoffMs\", () => {\n      it(\"uses retryAfterMs when provided\", () => {\n        expect(calculateBackoffMs(\"QUOTA_EXHAUSTED\", 0, 120_000)).toBe(120_000);\n        expect(calculateBackoffMs(\"RATE_LIMIT_EXCEEDED\", 0, 45_000)).toBe(45_000);\n      });\n\n      it(\"enforces minimum 2s backoff\", () => {\n        expect(calculateBackoffMs(\"QUOTA_EXHAUSTED\", 0, 500)).toBe(2_000);\n        expect(calculateBackoffMs(\"RATE_LIMIT_EXCEEDED\", 0, 1_000)).toBe(2_000);\n      });\n\n      it(\"applies exponential backoff for QUOTA_EXHAUSTED\", () => {\n        expect(calculateBackoffMs(\"QUOTA_EXHAUSTED\", 0)).toBe(60_000);\n        expect(calculateBackoffMs(\"QUOTA_EXHAUSTED\", 1)).toBe(300_000);\n        expect(calculateBackoffMs(\"QUOTA_EXHAUSTED\", 2)).toBe(1_800_000);\n        expect(calculateBackoffMs(\"QUOTA_EXHAUSTED\", 3)).toBe(7_200_000);\n        expect(calculateBackoffMs(\"QUOTA_EXHAUSTED\", 10)).toBe(7_200_000);\n      });\n\n      it(\"returns fixed backoff for RATE_LIMIT_EXCEEDED\", () => {\n        expect(calculateBackoffMs(\"RATE_LIMIT_EXCEEDED\", 0)).toBe(30_000);\n        expect(calculateBackoffMs(\"RATE_LIMIT_EXCEEDED\", 5)).toBe(30_000);\n      });\n\n      it(\"returns short backoff for MODEL_CAPACITY_EXHAUSTED\", () => {\n        // Base backoff is 45s with ±15s jitter (range: 30s to 60s)\n        const result = calculateBackoffMs(\"MODEL_CAPACITY_EXHAUSTED\", 0);\n        expect(result).toBeGreaterThanOrEqual(30_000);\n        expect(result).toBeLessThanOrEqual(60_000);\n      });\n\n      it(\"returns soft retry for SERVER_ERROR\", () => {\n        expect(calculateBackoffMs(\"SERVER_ERROR\", 0)).toBe(20_000);\n      });\n\n      it(\"returns default backoff for UNKNOWN\", () => {\n        expect(calculateBackoffMs(\"UNKNOWN\", 0)).toBe(60_000);\n      });\n    });\n\n    describe(\"markRateLimitedWithReason\", () => {\n      it(\"tracks consecutive failures and applies escalating backoff\", () => {\n        vi.useFakeTimers();\n        vi.setSystemTime(1000);\n\n        const stored: AccountStorageV4 = {\n          version: 4,\n          accounts: [\n            { refreshToken: \"r1\", projectId: \"p1\", addedAt: 1, lastUsed: 0 },\n          ],\n          activeIndex: 0,\n        };\n\n        const manager = new AccountManager(undefined, stored);\n        const account = manager.getAccounts()[0]!;\n\n        const backoff1 = manager.markRateLimitedWithReason(\n          account, \"gemini\", \"antigravity\", null, \"QUOTA_EXHAUSTED\"\n        );\n        expect(backoff1).toBe(60_000);\n        expect(account.consecutiveFailures).toBe(1);\n\n        const backoff2 = manager.markRateLimitedWithReason(\n          account, \"gemini\", \"antigravity\", null, \"QUOTA_EXHAUSTED\"\n        );\n        expect(backoff2).toBe(300_000);\n        expect(account.consecutiveFailures).toBe(2);\n\n        const backoff3 = manager.markRateLimitedWithReason(\n          account, \"gemini\", \"antigravity\", null, \"QUOTA_EXHAUSTED\"\n        );\n        expect(backoff3).toBe(1_800_000);\n        expect(account.consecutiveFailures).toBe(3);\n\n        vi.useRealTimers();\n      });\n\n      it(\"uses provided retryAfterMs over calculated backoff\", () => {\n        vi.useFakeTimers();\n        vi.setSystemTime(1000);\n\n        const stored: AccountStorageV4 = {\n          version: 4,\n          accounts: [\n            { refreshToken: \"r1\", projectId: \"p1\", addedAt: 1, lastUsed: 0 },\n          ],\n          activeIndex: 0,\n        };\n\n        const manager = new AccountManager(undefined, stored);\n        const account = manager.getAccounts()[0]!;\n\n        const backoff = manager.markRateLimitedWithReason(\n          account, \"gemini\", \"antigravity\", null, \"QUOTA_EXHAUSTED\", 180_000\n        );\n        expect(backoff).toBe(180_000);\n\n        vi.useRealTimers();\n      });\n    });\n\n    describe(\"markRequestSuccess\", () => {\n      it(\"resets consecutive failure counter\", () => {\n        const stored: AccountStorageV4 = {\n          version: 4,\n          accounts: [\n            { refreshToken: \"r1\", projectId: \"p1\", addedAt: 1, lastUsed: 0 },\n          ],\n          activeIndex: 0,\n        };\n\n        const manager = new AccountManager(undefined, stored);\n        const account = manager.getAccounts()[0]!;\n\n        account.consecutiveFailures = 5;\n        manager.markRequestSuccess(account);\n        expect(account.consecutiveFailures).toBe(0);\n      });\n    });\n\n    describe(\"Optimistic Reset\", () => {\n      it(\"shouldTryOptimisticReset returns true when min wait time <= 2s\", () => {\n        vi.useFakeTimers();\n        vi.setSystemTime(10_000);\n\n        const stored: AccountStorageV4 = {\n          version: 4,\n          accounts: [\n            { refreshToken: \"r1\", projectId: \"p1\", addedAt: 1, lastUsed: 0, rateLimitResetTimes: { \"gemini-antigravity\": 11_500, \"gemini-cli\": 11_500 } },\n          ],\n          activeIndex: 0,\n        };\n\n        const manager = new AccountManager(undefined, stored);\n        expect(manager.shouldTryOptimisticReset(\"gemini\")).toBe(true);\n\n        vi.useRealTimers();\n      });\n\n      it(\"shouldTryOptimisticReset returns false when min wait time > 2s\", () => {\n        vi.useFakeTimers();\n        vi.setSystemTime(10_000);\n\n        const stored: AccountStorageV4 = {\n          version: 4,\n          accounts: [\n            { refreshToken: \"r1\", projectId: \"p1\", addedAt: 1, lastUsed: 0, rateLimitResetTimes: { \"gemini-antigravity\": 15_000, \"gemini-cli\": 15_000 } },\n          ],\n          activeIndex: 0,\n        };\n\n        const manager = new AccountManager(undefined, stored);\n        expect(manager.shouldTryOptimisticReset(\"gemini\")).toBe(false);\n\n        vi.useRealTimers();\n      });\n\n      it(\"shouldTryOptimisticReset returns false when accounts are available\", () => {\n        const stored: AccountStorageV4 = {\n          version: 4,\n          accounts: [\n            { refreshToken: \"r1\", projectId: \"p1\", addedAt: 1, lastUsed: 0 },\n          ],\n          activeIndex: 0,\n        };\n\n        const manager = new AccountManager(undefined, stored);\n        expect(manager.shouldTryOptimisticReset(\"gemini\")).toBe(false);\n      });\n\n      it(\"clearAllRateLimitsForFamily clears rate limits and failure counters\", () => {\n        vi.useFakeTimers();\n        vi.setSystemTime(10_000);\n\n        const stored: AccountStorageV4 = {\n          version: 4,\n          accounts: [\n            { refreshToken: \"r1\", projectId: \"p1\", addedAt: 1, lastUsed: 0, rateLimitResetTimes: { \"gemini-antigravity\": 70_000, \"gemini-cli\": 80_000 } },\n            { refreshToken: \"r2\", projectId: \"p2\", addedAt: 2, lastUsed: 0, rateLimitResetTimes: { \"gemini-antigravity\": 90_000 } },\n          ],\n          activeIndex: 0,\n        };\n\n        const manager = new AccountManager(undefined, stored);\n        const accounts = manager.getAccounts();\n        accounts[0]!.consecutiveFailures = 3;\n        accounts[1]!.consecutiveFailures = 2;\n\n        manager.clearAllRateLimitsForFamily(\"gemini\");\n\n        expect(accounts[0]!.rateLimitResetTimes[\"gemini-antigravity\"]).toBeUndefined();\n        expect(accounts[0]!.rateLimitResetTimes[\"gemini-cli\"]).toBeUndefined();\n        expect(accounts[1]!.rateLimitResetTimes[\"gemini-antigravity\"]).toBeUndefined();\n        expect(accounts[0]!.consecutiveFailures).toBe(0);\n        expect(accounts[1]!.consecutiveFailures).toBe(0);\n\n        vi.useRealTimers();\n      });\n    });\n  });\n\n  describe(\"Failure TTL Expiration\", () => {\n    it(\"resets consecutiveFailures when lastFailureTime exceeds TTL\", () => {\n      vi.useFakeTimers();\n      vi.setSystemTime(new Date(0));\n\n      const stored: AccountStorageV4 = {\n        version: 4,\n        accounts: [\n          { refreshToken: \"r1\", projectId: \"p1\", addedAt: 1, lastUsed: 0 },\n        ],\n        activeIndex: 0,\n      };\n\n      const manager = new AccountManager(undefined, stored);\n      const account = manager.getCurrentOrNextForFamily(\"claude\");\n\n      // First failure\n      manager.markRateLimitedWithReason(account!, \"claude\", \"antigravity\", null, \"QUOTA_EXHAUSTED\", null, 3600_000);\n      expect(account!.consecutiveFailures).toBe(1);\n      expect(account!.lastFailureTime).toBe(0);\n\n      // Advance time past TTL (1 hour = 3600s)\n      vi.setSystemTime(new Date(3700_000)); // 3700 seconds later\n\n      // Next failure should reset count because TTL expired\n      manager.markRateLimitedWithReason(account!, \"claude\", \"antigravity\", null, \"QUOTA_EXHAUSTED\", null, 3600_000);\n      expect(account!.consecutiveFailures).toBe(1); // Reset to 0, then +1\n\n      vi.useRealTimers();\n    });\n\n    it(\"keeps consecutiveFailures when within TTL\", () => {\n      vi.useFakeTimers();\n      vi.setSystemTime(new Date(0));\n\n      const stored: AccountStorageV4 = {\n        version: 4,\n        accounts: [\n          { refreshToken: \"r1\", projectId: \"p1\", addedAt: 1, lastUsed: 0 },\n        ],\n        activeIndex: 0,\n      };\n\n      const manager = new AccountManager(undefined, stored);\n      const account = manager.getCurrentOrNextForFamily(\"claude\");\n\n      // First failure\n      manager.markRateLimitedWithReason(account!, \"claude\", \"antigravity\", null, \"QUOTA_EXHAUSTED\", null, 3600_000);\n      expect(account!.consecutiveFailures).toBe(1);\n\n      // Advance time within TTL\n      vi.setSystemTime(new Date(1800_000)); // 30 minutes later (within 1 hour TTL)\n\n      // Next failure should increment\n      manager.markRateLimitedWithReason(account!, \"claude\", \"antigravity\", null, \"QUOTA_EXHAUSTED\", null, 3600_000);\n      expect(account!.consecutiveFailures).toBe(2);\n\n      vi.useRealTimers();\n    });\n  });\n\n  describe(\"Fingerprint History\", () => {\n    it(\"regenerateAccountFingerprint saves old fingerprint to history\", () => {\n      const stored: AccountStorageV4 = {\n        version: 4,\n        accounts: [\n          { refreshToken: \"r1\", projectId: \"p1\", addedAt: 1, lastUsed: 0 },\n        ],\n        activeIndex: 0,\n      };\n\n      const manager = new AccountManager(undefined, stored);\n      const account = manager.getCurrentOrNextForFamily(\"claude\");\n      \n      // Set initial fingerprint\n      const originalFingerprint = account!.fingerprint;\n      \n      // Regenerate\n      const newFingerprint = manager.regenerateAccountFingerprint(0);\n      \n      expect(newFingerprint).not.toBeNull();\n      expect(newFingerprint).not.toEqual(originalFingerprint);\n      expect(account!.fingerprintHistory?.length).toBeGreaterThanOrEqual(0);\n    });\n\n    it(\"restoreAccountFingerprint restores from history\", () => {\n      vi.useFakeTimers();\n      vi.setSystemTime(new Date(1000)); // Start at 1000 to avoid 0 being falsy\n\n      const stored: AccountStorageV4 = {\n        version: 4,\n        accounts: [\n          { refreshToken: \"r1\", projectId: \"p1\", addedAt: 1, lastUsed: 0 },\n        ],\n        activeIndex: 0,\n      };\n\n      const manager = new AccountManager(undefined, stored);\n      manager.getCurrentOrNextForFamily(\"claude\");\n      \n      // Generate initial fingerprint\n      const original = manager.regenerateAccountFingerprint(0);\n      const originalDeviceId = original?.deviceId;\n      \n      vi.setSystemTime(new Date(2000));\n      \n      // Generate second fingerprint (pushes first to history at index 0)\n      manager.regenerateAccountFingerprint(0);\n      \n      // History[0] should be the \"original\" fingerprint\n      const history = manager.getAccountFingerprintHistory(0);\n      expect(history.length).toBeGreaterThanOrEqual(1);\n      expect(history[0]?.fingerprint.deviceId).toBe(originalDeviceId);\n      \n      vi.setSystemTime(new Date(3000));\n      \n      // Restore from history[0] - should get the \"original\" back\n      // Note: restore also pushes current to history, so after restore:\n      // - Current = original fingerprint\n      // - History[0] = what was current before restore\n      const restored = manager.restoreAccountFingerprint(0, 0);\n      \n      expect(restored).not.toBeNull();\n      expect(restored?.deviceId).toBe(originalDeviceId);\n\n      vi.useRealTimers();\n    });\n\n    it(\"getAccountFingerprintHistory returns empty array for new account\", () => {\n      const stored: AccountStorageV4 = {\n        version: 4,\n        accounts: [\n          { refreshToken: \"r1\", projectId: \"p1\", addedAt: 1, lastUsed: 0 },\n        ],\n        activeIndex: 0,\n      };\n\n      const manager = new AccountManager(undefined, stored);\n      \n      const history = manager.getAccountFingerprintHistory(0);\n      expect(history).toEqual([]);\n    });\n\n    it(\"limits fingerprint history to MAX_FINGERPRINT_HISTORY\", () => {\n      const stored: AccountStorageV4 = {\n        version: 4,\n        accounts: [\n          { refreshToken: \"r1\", projectId: \"p1\", addedAt: 1, lastUsed: 0 },\n        ],\n        activeIndex: 0,\n      };\n\n      const manager = new AccountManager(undefined, stored);\n      \n      // Regenerate 7 times (should only keep 5 in history)\n      for (let i = 0; i < 7; i++) {\n        manager.regenerateAccountFingerprint(0);\n      }\n      \n      const history = manager.getAccountFingerprintHistory(0);\n      expect(history.length).toBeLessThanOrEqual(5);\n    });\n  });\n\n  describe(\"soft quota threshold\", () => {\n    it(\"skips account over soft quota threshold in sticky mode\", () => {\n      const stored: AccountStorageV4 = {\n        version: 4,\n        accounts: [\n          { refreshToken: \"r1\", projectId: \"p1\", addedAt: 1, lastUsed: 0 },\n          { refreshToken: \"r2\", projectId: \"p2\", addedAt: 2, lastUsed: 0 },\n        ],\n        activeIndex: 0,\n      };\n\n      const manager = new AccountManager(undefined, stored);\n      manager.updateQuotaCache(0, { claude: { remainingFraction: 0.05, modelCount: 1 } });\n\n      const account = manager.getCurrentOrNextForFamily(\"claude\", null, \"sticky\", \"antigravity\", false, 90);\n      expect(account?.parts.refreshToken).toBe(\"r2\");\n    });\n\n    it(\"allows account under soft quota threshold\", () => {\n      const stored: AccountStorageV4 = {\n        version: 4,\n        accounts: [\n          { refreshToken: \"r1\", projectId: \"p1\", addedAt: 1, lastUsed: 0 },\n        ],\n        activeIndex: 0,\n      };\n\n      const manager = new AccountManager(undefined, stored);\n      manager.updateQuotaCache(0, { claude: { remainingFraction: 0.15, modelCount: 1 } });\n\n      const account = manager.getCurrentOrNextForFamily(\"claude\", null, \"sticky\", \"antigravity\", false, 90);\n      expect(account?.parts.refreshToken).toBe(\"r1\");\n    });\n\n    it(\"threshold of 100 disables soft quota protection\", () => {\n      const stored: AccountStorageV4 = {\n        version: 4,\n        accounts: [\n          { refreshToken: \"r1\", projectId: \"p1\", addedAt: 1, lastUsed: 0 },\n        ],\n        activeIndex: 0,\n      };\n\n      const manager = new AccountManager(undefined, stored);\n      manager.updateQuotaCache(0, { claude: { remainingFraction: 0.01, modelCount: 1 } });\n\n      const account = manager.getCurrentOrNextForFamily(\"claude\", null, \"sticky\", \"antigravity\", false, 100);\n      expect(account?.parts.refreshToken).toBe(\"r1\");\n    });\n\n    it(\"returns null when all accounts over threshold\", () => {\n      const stored: AccountStorageV4 = {\n        version: 4,\n        accounts: [\n          { refreshToken: \"r1\", projectId: \"p1\", addedAt: 1, lastUsed: 0 },\n          { refreshToken: \"r2\", projectId: \"p2\", addedAt: 2, lastUsed: 0 },\n        ],\n        activeIndex: 0,\n      };\n\n      const manager = new AccountManager(undefined, stored);\n      manager.updateQuotaCache(0, { claude: { remainingFraction: 0.05, modelCount: 1 } });\n      manager.updateQuotaCache(1, { claude: { remainingFraction: 0.08, modelCount: 1 } });\n\n      const account = manager.getCurrentOrNextForFamily(\"claude\", null, \"sticky\", \"antigravity\", false, 90);\n      expect(account).toBeNull();\n    });\n\n    it(\"skips account over threshold in round-robin mode\", () => {\n      const stored: AccountStorageV4 = {\n        version: 4,\n        accounts: [\n          { refreshToken: \"r1\", projectId: \"p1\", addedAt: 1, lastUsed: 0 },\n          { refreshToken: \"r2\", projectId: \"p2\", addedAt: 2, lastUsed: 0 },\n        ],\n        activeIndex: 0,\n      };\n\n      const manager = new AccountManager(undefined, stored);\n      manager.updateQuotaCache(0, { claude: { remainingFraction: 0.05, modelCount: 1 } });\n\n      const account = manager.getCurrentOrNextForFamily(\"claude\", null, \"round-robin\", \"antigravity\", false, 90);\n      expect(account?.parts.refreshToken).toBe(\"r2\");\n    });\n\n    it(\"account without cached quota is not skipped\", () => {\n      const stored: AccountStorageV4 = {\n        version: 4,\n        accounts: [\n          { refreshToken: \"r1\", projectId: \"p1\", addedAt: 1, lastUsed: 0 },\n        ],\n        activeIndex: 0,\n      };\n\n      const manager = new AccountManager(undefined, stored);\n\n      const account = manager.getCurrentOrNextForFamily(\"claude\", null, \"sticky\", \"antigravity\", false, 90);\n      expect(account?.parts.refreshToken).toBe(\"r1\");\n    });\n\n    it(\"handles remainingFraction of 0 (fully exhausted)\", () => {\n      const stored: AccountStorageV4 = {\n        version: 4,\n        accounts: [\n          { refreshToken: \"r1\", projectId: \"p1\", addedAt: 1, lastUsed: 0 },\n          { refreshToken: \"r2\", projectId: \"p2\", addedAt: 2, lastUsed: 0 },\n        ],\n        activeIndex: 0,\n      };\n\n      const manager = new AccountManager(undefined, stored);\n      manager.updateQuotaCache(0, { claude: { remainingFraction: 0, modelCount: 1 } });\n\n      const account = manager.getCurrentOrNextForFamily(\"claude\", null, \"sticky\", \"antigravity\", false, 90);\n      expect(account?.parts.refreshToken).toBe(\"r2\");\n    });\n\n    it(\"ignores stale quota cache (over 10 minutes old)\", () => {\n      vi.useFakeTimers();\n      vi.setSystemTime(new Date(0));\n\n      const stored: AccountStorageV4 = {\n        version: 4,\n        accounts: [\n          { refreshToken: \"r1\", projectId: \"p1\", addedAt: 1, lastUsed: 0 },\n        ],\n        activeIndex: 0,\n      };\n\n      const manager = new AccountManager(undefined, stored);\n      manager.updateQuotaCache(0, { claude: { remainingFraction: 0.05, modelCount: 1 } });\n\n      vi.setSystemTime(new Date(11 * 60 * 1000));\n\n      const account = manager.getCurrentOrNextForFamily(\"claude\", null, \"sticky\", \"antigravity\", false, 90);\n      expect(account?.parts.refreshToken).toBe(\"r1\");\n\n      vi.useRealTimers();\n    });\n\n    it(\"fails open when cachedQuotaUpdatedAt is missing\", () => {\n      const stored: AccountStorageV4 = {\n        version: 4,\n        accounts: [\n          { refreshToken: \"r1\", projectId: \"p1\", addedAt: 1, lastUsed: 0 },\n        ],\n        activeIndex: 0,\n      };\n\n      const manager = new AccountManager(undefined, stored);\n      const acc = (manager as any).accounts[0];\n      acc.cachedQuota = { claude: { remainingFraction: 0.05, modelCount: 1 } };\n      acc.cachedQuotaUpdatedAt = undefined;\n\n      const account = manager.getCurrentOrNextForFamily(\"claude\", null, \"sticky\", \"antigravity\", false, 90);\n      expect(account?.parts.refreshToken).toBe(\"r1\");\n    });\n  });\n\n  describe(\"getMinWaitTimeForSoftQuota\", () => {\n    it(\"returns 0 when accounts are available (under threshold)\", () => {\n      const stored: AccountStorageV4 = {\n        version: 4,\n        accounts: [\n          { refreshToken: \"r1\", projectId: \"p1\", addedAt: 1, lastUsed: 0 },\n        ],\n        activeIndex: 0,\n      };\n\n      const manager = new AccountManager(undefined, stored);\n      manager.updateQuotaCache(0, { claude: { remainingFraction: 0.15, modelCount: 1 } });\n\n      const waitMs = manager.getMinWaitTimeForSoftQuota(\"claude\", 90, 10 * 60 * 1000);\n      expect(waitMs).toBe(0);\n    });\n\n    it(\"returns null when no resetTime available\", () => {\n      const stored: AccountStorageV4 = {\n        version: 4,\n        accounts: [\n          { refreshToken: \"r1\", projectId: \"p1\", addedAt: 1, lastUsed: 0 },\n        ],\n        activeIndex: 0,\n      };\n\n      const manager = new AccountManager(undefined, stored);\n      manager.updateQuotaCache(0, { claude: { remainingFraction: 0.05, modelCount: 1 } });\n\n      const waitMs = manager.getMinWaitTimeForSoftQuota(\"claude\", 90, 10 * 60 * 1000);\n      expect(waitMs).toBeNull();\n    });\n\n    it(\"returns wait time from resetTime when over threshold\", () => {\n      vi.useFakeTimers();\n      vi.setSystemTime(new Date(\"2026-01-28T10:00:00Z\"));\n\n      const stored: AccountStorageV4 = {\n        version: 4,\n        accounts: [\n          { refreshToken: \"r1\", projectId: \"p1\", addedAt: 1, lastUsed: 0 },\n        ],\n        activeIndex: 0,\n      };\n\n      const manager = new AccountManager(undefined, stored);\n      manager.updateQuotaCache(0, { \n        claude: { \n          remainingFraction: 0.05, \n          resetTime: \"2026-01-28T15:00:00Z\",\n          modelCount: 1 \n        } \n      });\n\n      const waitMs = manager.getMinWaitTimeForSoftQuota(\"claude\", 90, 10 * 60 * 1000);\n      expect(waitMs).toBe(5 * 60 * 60 * 1000);\n\n      vi.useRealTimers();\n    });\n\n    it(\"returns null (fail-open) when resetTime is in the past\", () => {\n      vi.useFakeTimers();\n      vi.setSystemTime(new Date(\"2026-01-28T16:00:00Z\"));\n\n      const stored: AccountStorageV4 = {\n        version: 4,\n        accounts: [\n          { refreshToken: \"r1\", projectId: \"p1\", addedAt: 1, lastUsed: 0 },\n        ],\n        activeIndex: 0,\n      };\n\n      const manager = new AccountManager(undefined, stored);\n      manager.updateQuotaCache(0, { \n        claude: { \n          remainingFraction: 0.05, \n          resetTime: \"2026-01-28T15:00:00Z\",\n          modelCount: 1 \n        } \n      });\n\n      const waitMs = manager.getMinWaitTimeForSoftQuota(\"claude\", 90, 10 * 60 * 1000);\n      expect(waitMs).toBe(null);\n\n      vi.useRealTimers();\n    });\n\n    it(\"returns minimum wait time across multiple accounts\", () => {\n      vi.useFakeTimers();\n      vi.setSystemTime(new Date(\"2026-01-28T10:00:00Z\"));\n\n      const stored: AccountStorageV4 = {\n        version: 4,\n        accounts: [\n          { refreshToken: \"r1\", projectId: \"p1\", addedAt: 1, lastUsed: 0 },\n          { refreshToken: \"r2\", projectId: \"p2\", addedAt: 2, lastUsed: 0 },\n        ],\n        activeIndex: 0,\n      };\n\n      const manager = new AccountManager(undefined, stored);\n      manager.updateQuotaCache(0, { \n        claude: { remainingFraction: 0.05, resetTime: \"2026-01-28T15:00:00Z\", modelCount: 1 } \n      });\n      manager.updateQuotaCache(1, { \n        claude: { remainingFraction: 0.08, resetTime: \"2026-01-28T12:00:00Z\", modelCount: 1 } \n      });\n\n      const waitMs = manager.getMinWaitTimeForSoftQuota(\"claude\", 90, 10 * 60 * 1000);\n      expect(waitMs).toBe(2 * 60 * 60 * 1000);\n\n      vi.useRealTimers();\n    });\n  });\n});\n\ndescribe(\"resolveQuotaGroup\", () => {\n  it(\"returns model-based quota group when model is provided\", () => {\n    expect(resolveQuotaGroup(\"claude\", \"claude-opus-4-6-thinking\")).toBe(\"claude\");\n    expect(resolveQuotaGroup(\"gemini\", \"gemini-2.5-pro\")).toBe(\"gemini-pro\");\n    expect(resolveQuotaGroup(\"gemini\", \"gemini-2.5-flash\")).toBe(\"gemini-flash\");\n  });\n\n  it(\"falls back to claude for claude family when no model\", () => {\n    expect(resolveQuotaGroup(\"claude\", null)).toBe(\"claude\");\n    expect(resolveQuotaGroup(\"claude\", undefined)).toBe(\"claude\");\n  });\n\n  it(\"falls back to gemini-pro for gemini family when no model\", () => {\n    expect(resolveQuotaGroup(\"gemini\", null)).toBe(\"gemini-pro\");\n    expect(resolveQuotaGroup(\"gemini\", undefined)).toBe(\"gemini-pro\");\n  });\n\n  it(\"model takes precedence over family\", () => {\n    // Even if family says claude, model determines the quota group\n    expect(resolveQuotaGroup(\"gemini\", \"gemini-2.5-flash\")).toBe(\"gemini-flash\");\n    expect(resolveQuotaGroup(\"gemini\", \"gemini-3-pro\")).toBe(\"gemini-pro\");\n  });\n});\n"
  },
  {
    "path": "src/plugin/accounts.ts",
    "content": "import { formatRefreshParts, parseRefreshParts } from \"./auth\";\nimport { loadAccounts, saveAccounts, type AccountStorageV4, type AccountMetadataV3, type RateLimitStateV3, type ModelFamily, type HeaderStyle, type CooldownReason } from \"./storage\";\nimport type { OAuthAuthDetails, RefreshParts } from \"./types\";\nimport type { AccountSelectionStrategy } from \"./config/schema\";\nimport { getHealthTracker, getTokenTracker, selectHybridAccount, type AccountWithMetrics } from \"./rotation\";\nimport { generateFingerprint, updateFingerprintVersion, type Fingerprint, type FingerprintVersion, MAX_FINGERPRINT_HISTORY } from \"./fingerprint\";\nimport type { QuotaGroup, QuotaGroupSummary } from \"./quota\";\nimport { getModelFamily } from \"./transform/model-resolver\";\nimport { debugLogToFile } from \"./debug\";\nimport { formatAccountLabel } from \"./logging-utils\";\n\n\nexport type { ModelFamily, HeaderStyle, CooldownReason } from \"./storage\";\nexport type { AccountSelectionStrategy } from \"./config/schema\";\n\n\nexport type RateLimitReason = \n  | \"QUOTA_EXHAUSTED\"\n  | \"RATE_LIMIT_EXCEEDED\" \n  | \"MODEL_CAPACITY_EXHAUSTED\"\n  | \"SERVER_ERROR\"\n  | \"UNKNOWN\";\n\nexport interface RateLimitBackoffResult {\n  backoffMs: number;\n  reason: RateLimitReason;\n}\n\nconst QUOTA_EXHAUSTED_BACKOFFS = [60_000, 300_000, 1_800_000, 7_200_000] as const;\nconst RATE_LIMIT_EXCEEDED_BACKOFF = 30_000;\n// Increased from 15s to 45s base + jitter to reduce retry pressure on capacity errors\nconst MODEL_CAPACITY_EXHAUSTED_BASE_BACKOFF = 45_000;\nconst MODEL_CAPACITY_EXHAUSTED_JITTER_MAX = 30_000; // ±15s jitter range\nconst SERVER_ERROR_BACKOFF = 20_000;\nconst UNKNOWN_BACKOFF = 60_000;\nconst MIN_BACKOFF_MS = 2_000;\n\n/**\n * Generate a random jitter value for backoff timing.\n * Helps prevent thundering herd problem when multiple clients retry simultaneously.\n */\nfunction generateJitter(maxJitterMs: number): number {\n  return Math.random() * maxJitterMs - (maxJitterMs / 2);\n}\n\nexport function parseRateLimitReason(\n  reason: string | undefined, \n  message: string | undefined, \n  status?: number\n): RateLimitReason {\n  // 1. Status Code Checks (Rust parity)\n  // 529 = Site Overloaded, 503 = Service Unavailable -> Capacity issues\n  if (status === 529 || status === 503) return \"MODEL_CAPACITY_EXHAUSTED\";\n  // 500 = Internal Server Error -> Treat as Server Error (soft wait)\n  if (status === 500) return \"SERVER_ERROR\";\n\n  // 2. Explicit Reason String\n  if (reason) {\n    switch (reason.toUpperCase()) {\n      case \"QUOTA_EXHAUSTED\": return \"QUOTA_EXHAUSTED\";\n      case \"RATE_LIMIT_EXCEEDED\": return \"RATE_LIMIT_EXCEEDED\";\n      case \"MODEL_CAPACITY_EXHAUSTED\": return \"MODEL_CAPACITY_EXHAUSTED\";\n    }\n  }\n  \n  // 3. Message Text Scanning (Rust Regex parity)\n  if (message) {\n    const lower = message.toLowerCase();\n    \n    // Capacity / Overloaded (Transient) - Check FIRST before \"exhausted\"\n    if (lower.includes(\"capacity\") || lower.includes(\"overloaded\") || lower.includes(\"resource exhausted\")) {\n      return \"MODEL_CAPACITY_EXHAUSTED\";\n    }\n\n    // RPM / TPM (Short Wait)\n    // \"per minute\", \"rate limit\", \"too many requests\"\n    // \"presque\" (French: almost) - retained for i18n parity with Rust reference\n    if (lower.includes(\"per minute\") || lower.includes(\"rate limit\") || lower.includes(\"too many requests\") || lower.includes(\"presque\")) {\n      return \"RATE_LIMIT_EXCEEDED\";\n    }\n\n    // Quota (Long Wait)\n    if (lower.includes(\"exhausted\") || lower.includes(\"quota\")) {\n      return \"QUOTA_EXHAUSTED\";\n    }\n  }\n  \n  // Default fallback for 429 without clearer info\n  if (status === 429) {\n    return \"UNKNOWN\"; \n  }\n  \n  return \"UNKNOWN\";\n}\n\nexport function calculateBackoffMs(\n  reason: RateLimitReason,\n  consecutiveFailures: number,\n  retryAfterMs?: number | null\n): number {\n  // Respect explicit Retry-After header if reasonable\n  if (retryAfterMs && retryAfterMs > 0) {\n    // Rust uses 2s min buffer, we keep 2s\n    return Math.max(retryAfterMs, MIN_BACKOFF_MS);\n  }\n  \n  switch (reason) {\n    case \"QUOTA_EXHAUSTED\": {\n      const index = Math.min(consecutiveFailures, QUOTA_EXHAUSTED_BACKOFFS.length - 1);\n      return QUOTA_EXHAUSTED_BACKOFFS[index] ?? UNKNOWN_BACKOFF;\n    }\n    case \"RATE_LIMIT_EXCEEDED\":\n      return RATE_LIMIT_EXCEEDED_BACKOFF; // 30s\n    case \"MODEL_CAPACITY_EXHAUSTED\":\n      // Apply jitter to prevent thundering herd on capacity errors\n      return MODEL_CAPACITY_EXHAUSTED_BASE_BACKOFF + generateJitter(MODEL_CAPACITY_EXHAUSTED_JITTER_MAX);\n    case \"SERVER_ERROR\":\n      return SERVER_ERROR_BACKOFF; // 20s\n    case \"UNKNOWN\":\n    default:\n      return UNKNOWN_BACKOFF; // 60s\n  }\n}\n\nexport type BaseQuotaKey = \"claude\" | \"gemini-antigravity\" | \"gemini-cli\";\nexport type QuotaKey = BaseQuotaKey | `${BaseQuotaKey}:${string}`;\n\nexport interface ManagedAccount {\n  index: number;\n  email?: string;\n  addedAt: number;\n  lastUsed: number;\n  parts: RefreshParts;\n  access?: string;\n  expires?: number;\n  enabled: boolean;\n  rateLimitResetTimes: RateLimitStateV3;\n  lastSwitchReason?: \"rate-limit\" | \"initial\" | \"rotation\";\n  coolingDownUntil?: number;\n  cooldownReason?: CooldownReason;\n  touchedForQuota: Record<string, number>;\n  consecutiveFailures?: number;\n  /** Timestamp of last failure for TTL-based reset of consecutiveFailures */\n  lastFailureTime?: number;\n  /** Per-account device fingerprint for rate limit mitigation */\n  fingerprint?: import(\"./fingerprint\").Fingerprint;\n  /** History of previous fingerprints for this account */\n  fingerprintHistory?: FingerprintVersion[];\n  /** Cached quota data from last checkAccountsQuota() call */\n  cachedQuota?: Partial<Record<QuotaGroup, QuotaGroupSummary>>;\n  cachedQuotaUpdatedAt?: number;\n  verificationRequired?: boolean;\n  verificationRequiredAt?: number;\n  verificationRequiredReason?: string;\n  verificationUrl?: string;\n}\n\nfunction nowMs(): number {\n  return Date.now();\n}\n\nfunction clampNonNegativeInt(value: unknown, fallback: number): number {\n  if (typeof value !== \"number\" || !Number.isFinite(value)) {\n    return fallback;\n  }\n  return value < 0 ? 0 : Math.floor(value);\n}\n\nfunction getQuotaKey(family: ModelFamily, headerStyle: HeaderStyle, model?: string | null): QuotaKey {\n  if (family === \"claude\") {\n    return \"claude\";\n  }\n  const base = headerStyle === \"gemini-cli\" ? \"gemini-cli\" : \"gemini-antigravity\";\n  if (model) {\n    return `${base}:${model}`;\n  }\n  return base;\n}\n\nfunction isRateLimitedForQuotaKey(account: ManagedAccount, key: QuotaKey): boolean {\n  const resetTime = account.rateLimitResetTimes[key];\n  return resetTime !== undefined && nowMs() < resetTime;\n}\n\nfunction isRateLimitedForFamily(account: ManagedAccount, family: ModelFamily, model?: string | null): boolean {\n  if (family === \"claude\") {\n    return isRateLimitedForQuotaKey(account, \"claude\");\n  }\n  \n  const antigravityIsLimited = isRateLimitedForHeaderStyle(account, family, \"antigravity\", model);\n  const cliIsLimited = isRateLimitedForHeaderStyle(account, family, \"gemini-cli\", model);\n  \n  return antigravityIsLimited && cliIsLimited;\n}\n\nfunction isRateLimitedForHeaderStyle(account: ManagedAccount, family: ModelFamily, headerStyle: HeaderStyle, model?: string | null): boolean {\n  clearExpiredRateLimits(account);\n  \n  if (family === \"claude\") {\n    return isRateLimitedForQuotaKey(account, \"claude\");\n  }\n\n  // Check model-specific quota first if provided\n  if (model) {\n    const modelKey = getQuotaKey(family, headerStyle, model);\n    if (isRateLimitedForQuotaKey(account, modelKey)) {\n      return true;\n    }\n  }\n\n  // Then check base family quota\n  const baseKey = getQuotaKey(family, headerStyle);\n  return isRateLimitedForQuotaKey(account, baseKey);\n}\n\nfunction clearExpiredRateLimits(account: ManagedAccount): void {\n  const now = nowMs();\n  const keys = Object.keys(account.rateLimitResetTimes) as QuotaKey[];\n  for (const key of keys) {\n    const resetTime = account.rateLimitResetTimes[key];\n    if (resetTime !== undefined && now >= resetTime) {\n      delete account.rateLimitResetTimes[key];\n    }\n  }\n}\n\n/**\n * Resolve the quota group for soft quota checks.\n * \n * When a model string is available, we can precisely determine the quota group.\n * When model is null/undefined, we fall back based on family:\n * - Claude → \"claude\" quota group\n * - Gemini → \"gemini-pro\" (conservative fallback; may misclassify flash models)\n * \n * @param family - The model family (\"claude\" | \"gemini\")\n * @param model - Optional model string for precise resolution\n * @returns The QuotaGroup to use for soft quota checks\n */\nexport function resolveQuotaGroup(family: ModelFamily, model?: string | null): QuotaGroup {\n  if (model) {\n    return getModelFamily(model);\n  }\n  return family === \"claude\" ? \"claude\" : \"gemini-pro\";\n}\n\nfunction isOverSoftQuotaThreshold(\n  account: ManagedAccount,\n  family: ModelFamily,\n  thresholdPercent: number,\n  cacheTtlMs: number,\n  model?: string | null\n): boolean {\n  if (thresholdPercent >= 100) return false;\n  if (!account.cachedQuota) return false;\n  \n  if (account.cachedQuotaUpdatedAt == null) return false;\n  const age = nowMs() - account.cachedQuotaUpdatedAt;\n  if (age > cacheTtlMs) return false;\n  \n  const quotaGroup = resolveQuotaGroup(family, model);\n  \n  const groupData = account.cachedQuota[quotaGroup];\n  if (groupData?.remainingFraction == null) return false;\n  \n  const remainingFraction = Math.max(0, Math.min(1, groupData.remainingFraction));\n  const usedPercent = (1 - remainingFraction) * 100;\n  const isOverThreshold = usedPercent >= thresholdPercent;\n  \n  if (isOverThreshold) {\n    const accountLabel = formatAccountLabel(account.email, account.index);\n    const resetSuffix = groupData.resetTime ? ` (resets: ${groupData.resetTime})` : \"\";\n    const message = `[SoftQuota] Skipping ${accountLabel}: ${quotaGroup} usage ${usedPercent.toFixed(1)}% >= threshold ${thresholdPercent}%${resetSuffix}`;\n    debugLogToFile(message);\n  }\n  \n  return isOverThreshold;\n}\n\nexport function computeSoftQuotaCacheTtlMs(\n  ttlConfig: \"auto\" | number,\n  refreshIntervalMinutes: number\n): number {\n  if (ttlConfig === \"auto\") {\n    return Math.max(2 * refreshIntervalMinutes, 10) * 60 * 1000;\n  }\n  return ttlConfig * 60 * 1000;\n}\n\n/**\n * In-memory multi-account manager with sticky account selection.\n *\n * Uses the same account until it hits a rate limit (429), then switches.\n * Rate limits are tracked per-model-family (claude/gemini) so an account\n * rate-limited for Claude can still be used for Gemini.\n *\n * Source of truth for the pool is `antigravity-accounts.json`.\n */\nexport class AccountManager {\n  private accounts: ManagedAccount[] = [];\n  private cursor = 0;\n  private currentAccountIndexByFamily: Record<ModelFamily, number> = {\n    claude: -1,\n    gemini: -1,\n  };\n  private sessionOffsetApplied: Record<ModelFamily, boolean> = {\n    claude: false,\n    gemini: false,\n  };\n  private lastToastAccountIndex = -1;\n  private lastToastTime = 0;\n\n  private savePending = false;\n  private saveTimeout: ReturnType<typeof setTimeout> | null = null;\n  private savePromiseResolvers: Array<() => void> = [];\n\n  static async loadFromDisk(authFallback?: OAuthAuthDetails): Promise<AccountManager> {\n    const stored = await loadAccounts();\n    return new AccountManager(authFallback, stored);\n  }\n\n  constructor(authFallback?: OAuthAuthDetails, stored?: AccountStorageV4 | null) {\n    const authParts = authFallback ? parseRefreshParts(authFallback.refresh) : null;\n\n    if (stored && stored.accounts.length === 0) {\n      this.accounts = [];\n      this.cursor = 0;\n      return;\n    }\n\n    if (stored && stored.accounts.length > 0) {\n      const baseNow = nowMs();\n      this.accounts = stored.accounts\n        .map((acc, index): ManagedAccount | null => {\n          if (!acc.refreshToken || typeof acc.refreshToken !== \"string\") {\n            return null;\n          }\n          const matchesFallback = !!(\n            authFallback &&\n            authParts &&\n            authParts.refreshToken &&\n            acc.refreshToken === authParts.refreshToken\n          );\n\n          return {\n            index,\n            email: acc.email,\n            addedAt: clampNonNegativeInt(acc.addedAt, baseNow),\n            lastUsed: clampNonNegativeInt(acc.lastUsed, 0),\n            parts: {\n              refreshToken: acc.refreshToken,\n              projectId: acc.projectId,\n              managedProjectId: acc.managedProjectId,\n            },\n            access: matchesFallback ? authFallback?.access : undefined,\n            expires: matchesFallback ? authFallback?.expires : undefined,\n            enabled: acc.enabled !== false,\n            rateLimitResetTimes: acc.rateLimitResetTimes ?? {},\n            lastSwitchReason: acc.lastSwitchReason,\n            coolingDownUntil: acc.coolingDownUntil,\n            cooldownReason: acc.cooldownReason,\n            touchedForQuota: {},\n            fingerprint: acc.fingerprint ?? generateFingerprint(),\n            fingerprintHistory: acc.fingerprintHistory ?? [],\n            cachedQuota: acc.cachedQuota as Partial<Record<QuotaGroup, QuotaGroupSummary>> | undefined,\n            cachedQuotaUpdatedAt: acc.cachedQuotaUpdatedAt,\n            verificationRequired: acc.verificationRequired,\n            verificationRequiredAt: acc.verificationRequiredAt,\n            verificationRequiredReason: acc.verificationRequiredReason,\n            verificationUrl: acc.verificationUrl,\n          };\n        })\n        .filter((a): a is ManagedAccount => a !== null);\n\n      // Update fingerprint versions to match the current runtime version.\n      // Saved fingerprints may carry an older version string; this ensures\n      // they always reflect the latest fetched (or fallback) version.\n      let fingerprintVersionChanged = false;\n      for (const acc of this.accounts) {\n        if (acc.fingerprint && updateFingerprintVersion(acc.fingerprint)) {\n          fingerprintVersionChanged = true;\n        }\n      }\n\n      this.cursor = clampNonNegativeInt(stored.activeIndex, 0);\n      if (this.accounts.length > 0) {\n        this.cursor = this.cursor % this.accounts.length;\n        const defaultIndex = this.cursor;\n        this.currentAccountIndexByFamily.claude = clampNonNegativeInt(\n          stored.activeIndexByFamily?.claude,\n          defaultIndex\n        ) % this.accounts.length;\n        this.currentAccountIndexByFamily.gemini = clampNonNegativeInt(\n          stored.activeIndexByFamily?.gemini,\n          defaultIndex\n        ) % this.accounts.length;\n      }\n\n      // Persist updated fingerprint versions to disk\n      if (fingerprintVersionChanged) {\n        this.requestSaveToDisk();\n      }\n\n      return;\n    }\n\n    // If we have stored accounts, check if we need to add the current auth\n    if (authFallback && this.accounts.length > 0) {\n      const authParts = parseRefreshParts(authFallback.refresh);\n      const hasMatching = this.accounts.some(acc => acc.parts.refreshToken === authParts.refreshToken);\n      if (!hasMatching && authParts.refreshToken) {\n        const now = nowMs();\n        const newAccount: ManagedAccount = {\n          index: this.accounts.length,\n          email: undefined,\n          addedAt: now,\n          lastUsed: 0,\n          parts: authParts,\n          access: authFallback.access,\n          expires: authFallback.expires,\n          enabled: true,\n          rateLimitResetTimes: {},\n          touchedForQuota: {},\n        };\n        this.accounts.push(newAccount);\n        // Update indices to include the new account\n        this.currentAccountIndexByFamily.claude = Math.min(this.currentAccountIndexByFamily.claude, this.accounts.length - 1);\n        this.currentAccountIndexByFamily.gemini = Math.min(this.currentAccountIndexByFamily.gemini, this.accounts.length - 1);\n      }\n    }\n\n    if (authFallback) {\n      const parts = parseRefreshParts(authFallback.refresh);\n      if (parts.refreshToken) {\n        const now = nowMs();\n        this.accounts = [\n          {\n            index: 0,\n            email: undefined,\n            addedAt: now,\n            lastUsed: 0,\n            parts,\n            access: authFallback.access,\n            expires: authFallback.expires,\n            enabled: true,\n            rateLimitResetTimes: {},\n            touchedForQuota: {},\n          },\n        ];\n        this.cursor = 0;\n        this.currentAccountIndexByFamily.claude = 0;\n        this.currentAccountIndexByFamily.gemini = 0;\n      }\n    }\n  }\n\n  getAccountCount(): number {\n    return this.getEnabledAccounts().length;\n  }\n\n  getTotalAccountCount(): number {\n    return this.accounts.length;\n  }\n\n  getEnabledAccounts(): ManagedAccount[] {\n    return this.accounts.filter((account) => account.enabled !== false);\n  }\n\n  getAccountsSnapshot(): ManagedAccount[] {\n    return this.accounts.map((a) => ({ ...a, parts: { ...a.parts }, rateLimitResetTimes: { ...a.rateLimitResetTimes } }));\n  }\n\n  getCurrentAccountForFamily(family: ModelFamily): ManagedAccount | null {\n    const currentIndex = this.currentAccountIndexByFamily[family];\n    if (currentIndex >= 0 && currentIndex < this.accounts.length) {\n      const account = this.accounts[currentIndex] ?? null;\n      // Only return account if it's enabled - disabled accounts should not be selected\n      if (account && account.enabled !== false) {\n        return account;\n      }\n    }\n    return null;\n  }\n\n  markSwitched(account: ManagedAccount, reason: \"rate-limit\" | \"initial\" | \"rotation\", family: ModelFamily): void {\n    account.lastSwitchReason = reason;\n    this.currentAccountIndexByFamily[family] = account.index;\n  }\n\n  /**\n   * Check if we should show an account switch toast.\n   * Debounces repeated toasts for the same account.\n   */\n  shouldShowAccountToast(accountIndex: number, debounceMs = 30000): boolean {\n    const now = nowMs();\n    if (accountIndex !== this.lastToastAccountIndex) {\n      return true;\n    }\n    return now - this.lastToastTime >= debounceMs;\n  }\n\n  markToastShown(accountIndex: number): void {\n    this.lastToastAccountIndex = accountIndex;\n    this.lastToastTime = nowMs();\n  }\n\n  getCurrentOrNextForFamily(\n    family: ModelFamily, \n    model?: string | null,\n    strategy: AccountSelectionStrategy = 'sticky',\n    headerStyle: HeaderStyle = 'antigravity',\n    pidOffsetEnabled: boolean = false,\n    softQuotaThresholdPercent: number = 100,\n    softQuotaCacheTtlMs: number = 10 * 60 * 1000,\n  ): ManagedAccount | null {\n    const quotaKey = getQuotaKey(family, headerStyle, model);\n\n    if (strategy === 'round-robin') {\n      const next = this.getNextForFamily(family, model, headerStyle, softQuotaThresholdPercent, softQuotaCacheTtlMs);\n      if (next) {\n        this.markTouchedForQuota(next, quotaKey);\n        this.currentAccountIndexByFamily[family] = next.index;\n      }\n      return next;\n    }\n\n    if (strategy === 'hybrid') {\n      const healthTracker = getHealthTracker();\n      const tokenTracker = getTokenTracker();\n      \n      const accountsWithMetrics: AccountWithMetrics[] = this.accounts\n        .filter(acc => acc.enabled !== false)\n        .map(acc => {\n          clearExpiredRateLimits(acc);\n          return {\n            index: acc.index,\n            lastUsed: acc.lastUsed,\n            healthScore: healthTracker.getScore(acc.index),\n            isRateLimited: isRateLimitedForFamily(acc, family, model) || \n                          isOverSoftQuotaThreshold(acc, family, softQuotaThresholdPercent, softQuotaCacheTtlMs, model),\n            isCoolingDown: this.isAccountCoolingDown(acc),\n          };\n        });\n\n      // Get current account index for stickiness\n      const currentIndex = this.currentAccountIndexByFamily[family] ?? null;\n      \n      const selectedIndex = selectHybridAccount(accountsWithMetrics, tokenTracker, currentIndex);\n      if (selectedIndex !== null) {\n        const selected = this.accounts[selectedIndex];\n        if (selected) {\n          selected.lastUsed = nowMs();\n          this.markTouchedForQuota(selected, quotaKey);\n          this.currentAccountIndexByFamily[family] = selected.index;\n          return selected;\n        }\n      }\n    }\n\n    // Fallback: sticky selection (used when hybrid finds no candidates)\n    // PID-based offset for multi-session distribution (opt-in)\n    // Different sessions (PIDs) will prefer different starting accounts\n    if (pidOffsetEnabled && !this.sessionOffsetApplied[family] && this.accounts.length > 1) {\n      const pidOffset = process.pid % this.accounts.length;\n      const baseIndex = this.currentAccountIndexByFamily[family] ?? 0;\n      const newIndex = (baseIndex + pidOffset) % this.accounts.length;\n      \n      debugLogToFile(`[Account] Applying PID offset: pid=${process.pid} offset=${pidOffset} family=${family} index=${baseIndex}->${newIndex}`);\n      \n      this.currentAccountIndexByFamily[family] = newIndex;\n      this.sessionOffsetApplied[family] = true;\n    }\n\n    const current = this.getCurrentAccountForFamily(family);\n    if (current) {\n      clearExpiredRateLimits(current);\n      const isLimitedForRequestedStyle = isRateLimitedForHeaderStyle(current, family, headerStyle, model);\n      const isOverThreshold = isOverSoftQuotaThreshold(current, family, softQuotaThresholdPercent, softQuotaCacheTtlMs, model);\n      if (!isLimitedForRequestedStyle && !isOverThreshold && !this.isAccountCoolingDown(current)) {\n        this.markTouchedForQuota(current, quotaKey);\n        return current;\n      }\n    }\n\n    const next = this.getNextForFamily(family, model, headerStyle, softQuotaThresholdPercent, softQuotaCacheTtlMs);\n    if (next) {\n      this.markTouchedForQuota(next, quotaKey);\n      this.currentAccountIndexByFamily[family] = next.index;\n    }\n    return next;\n  }\n\n  getNextForFamily(family: ModelFamily, model?: string | null, headerStyle: HeaderStyle = \"antigravity\", softQuotaThresholdPercent: number = 100, softQuotaCacheTtlMs: number = 10 * 60 * 1000): ManagedAccount | null {\n    const available = this.accounts.filter((a) => {\n      clearExpiredRateLimits(a);\n      return a.enabled !== false && \n             !isRateLimitedForHeaderStyle(a, family, headerStyle, model) && \n             !isOverSoftQuotaThreshold(a, family, softQuotaThresholdPercent, softQuotaCacheTtlMs, model) &&\n             !this.isAccountCoolingDown(a);\n    });\n\n    if (available.length === 0) {\n      return null;\n    }\n\n    const account = available[this.cursor % available.length];\n    if (!account) {\n      return null;\n    }\n\n    this.cursor++;\n    // Note: lastUsed is now updated after successful request via markAccountUsed()\n    return account;\n  }\n\n  markRateLimited(\n    account: ManagedAccount,\n    retryAfterMs: number,\n    family: ModelFamily,\n    headerStyle: HeaderStyle = \"antigravity\",\n    model?: string | null\n  ): void {\n    const key = getQuotaKey(family, headerStyle, model);\n    account.rateLimitResetTimes[key] = nowMs() + retryAfterMs;\n  }\n\n  /**\n   * Mark an account as used after a successful API request.\n   * This updates the lastUsed timestamp for freshness calculations.\n   * Should be called AFTER request completion, not during account selection.\n   */\n  markAccountUsed(accountIndex: number): void {\n    const account = this.accounts.find(a => a.index === accountIndex);\n    if (account) {\n      account.lastUsed = nowMs();\n    }\n  }\n\n  markRateLimitedWithReason(\n    account: ManagedAccount,\n    family: ModelFamily,\n    headerStyle: HeaderStyle,\n    model: string | null | undefined,\n    reason: RateLimitReason,\n    retryAfterMs?: number | null,\n    failureTtlMs: number = 3600_000, // Default 1 hour TTL\n  ): number {\n    const now = nowMs();\n    \n    // TTL-based reset: if last failure was more than failureTtlMs ago, reset count\n    if (account.lastFailureTime !== undefined && (now - account.lastFailureTime) > failureTtlMs) {\n      account.consecutiveFailures = 0;\n    }\n    \n    const failures = (account.consecutiveFailures ?? 0) + 1;\n    account.consecutiveFailures = failures;\n    account.lastFailureTime = now;\n    \n    const backoffMs = calculateBackoffMs(reason, failures - 1, retryAfterMs);\n    const key = getQuotaKey(family, headerStyle, model);\n    account.rateLimitResetTimes[key] = now + backoffMs;\n    \n    return backoffMs;\n  }\n\n  markRequestSuccess(account: ManagedAccount): void {\n    if (account.consecutiveFailures) {\n      account.consecutiveFailures = 0;\n    }\n  }\n\n  clearAllRateLimitsForFamily(family: ModelFamily, model?: string | null): void {\n    for (const account of this.accounts) {\n      if (family === \"claude\") {\n        delete account.rateLimitResetTimes.claude;\n      } else {\n        const antigravityKey = getQuotaKey(family, \"antigravity\", model);\n        const cliKey = getQuotaKey(family, \"gemini-cli\", model);\n        delete account.rateLimitResetTimes[antigravityKey];\n        delete account.rateLimitResetTimes[cliKey];\n      }\n      account.consecutiveFailures = 0;\n    }\n  }\n\n  shouldTryOptimisticReset(family: ModelFamily, model?: string | null): boolean {\n    const minWaitMs = this.getMinWaitTimeForFamily(family, model);\n    return minWaitMs > 0 && minWaitMs <= 2_000;\n  }\n\n  markAccountCoolingDown(account: ManagedAccount, cooldownMs: number, reason: CooldownReason): void {\n    account.coolingDownUntil = nowMs() + cooldownMs;\n    account.cooldownReason = reason;\n  }\n\n  isAccountCoolingDown(account: ManagedAccount): boolean {\n    if (account.coolingDownUntil === undefined) {\n      return false;\n    }\n    if (nowMs() >= account.coolingDownUntil) {\n      this.clearAccountCooldown(account);\n      return false;\n    }\n    return true;\n  }\n\n  clearAccountCooldown(account: ManagedAccount): void {\n    delete account.coolingDownUntil;\n    delete account.cooldownReason;\n  }\n\n  getAccountCooldownReason(account: ManagedAccount): CooldownReason | undefined {\n    return this.isAccountCoolingDown(account) ? account.cooldownReason : undefined;\n  }\n\n  markTouchedForQuota(account: ManagedAccount, quotaKey: string): void {\n    account.touchedForQuota[quotaKey] = nowMs();\n  }\n\n  isFreshForQuota(account: ManagedAccount, quotaKey: string): boolean {\n    const touchedAt = account.touchedForQuota[quotaKey];\n    if (!touchedAt) return true;\n    \n    const resetTime = account.rateLimitResetTimes[quotaKey as QuotaKey];\n    if (resetTime && touchedAt < resetTime) return true;\n    \n    return false;\n  }\n\n  getFreshAccountsForQuota(quotaKey: string, family: ModelFamily, model?: string | null): ManagedAccount[] {\n    return this.accounts.filter(acc => {\n      clearExpiredRateLimits(acc);\n      return acc.enabled !== false &&\n             this.isFreshForQuota(acc, quotaKey) && \n             !isRateLimitedForFamily(acc, family, model) && \n             !this.isAccountCoolingDown(acc);\n    });\n  }\n\n  isRateLimitedForHeaderStyle(\n    account: ManagedAccount,\n    family: ModelFamily,\n    headerStyle: HeaderStyle,\n    model?: string | null\n  ): boolean {\n    return isRateLimitedForHeaderStyle(account, family, headerStyle, model);\n  }\n\n  getAvailableHeaderStyle(account: ManagedAccount, family: ModelFamily, model?: string | null): HeaderStyle | null {\n    clearExpiredRateLimits(account);\n    if (family === \"claude\") {\n      return isRateLimitedForHeaderStyle(account, family, \"antigravity\") ? null : \"antigravity\";\n    }\n    if (!isRateLimitedForHeaderStyle(account, family, \"antigravity\", model)) {\n      return \"antigravity\";\n    }\n    if (!isRateLimitedForHeaderStyle(account, family, \"gemini-cli\", model)) {\n      return \"gemini-cli\";\n    }\n    return null;\n  }\n\n  /**\n   * Check if any OTHER account has antigravity quota available for the given family/model.\n   * \n   * Used to determine whether to switch accounts vs fall back to gemini-cli:\n   * - If true: Switch to another account (preserve antigravity priority)\n   * - If false: All accounts exhausted antigravity, safe to fall back to gemini-cli\n   * \n   * @param currentAccountIndex - Index of the current account (will be excluded from check)\n   * @param family - Model family (\"gemini\" or \"claude\")\n   * @param model - Optional model name for model-specific rate limits\n   * @returns true if any other enabled, non-cooling-down account has antigravity available\n   */\n  hasOtherAccountWithAntigravityAvailable(\n    currentAccountIndex: number,\n    family: ModelFamily,\n    model?: string | null\n  ): boolean {\n    // Claude has no gemini-cli fallback - always return false\n    // (This method is only relevant for Gemini's dual quota pools)\n    if (family === \"claude\") {\n      return false;\n    }\n\n    return this.accounts.some(acc => {\n      // Skip current account\n      if (acc.index === currentAccountIndex) {\n        return false;\n      }\n      // Skip disabled accounts\n      if (acc.enabled === false) {\n        return false;\n      }\n      // Skip cooling down accounts\n      if (this.isAccountCoolingDown(acc)) {\n        return false;\n      }\n      // Clear expired rate limits before checking\n      clearExpiredRateLimits(acc);\n      // Check if antigravity is available for this account\n      return !isRateLimitedForHeaderStyle(acc, family, \"antigravity\", model);\n    });\n  }\n\n  setAccountEnabled(accountIndex: number, enabled: boolean): boolean {\n    const account = this.accounts[accountIndex];\n    if (!account) {\n      return false;\n    }\n    account.enabled = enabled;\n\n    if (!enabled) {\n      for (const family of Object.keys(this.currentAccountIndexByFamily) as ModelFamily[]) {\n        if (this.currentAccountIndexByFamily[family] === accountIndex) {\n          const next = this.accounts.find((a, i) => i !== accountIndex && a.enabled !== false);\n          this.currentAccountIndexByFamily[family] = next?.index ?? -1;\n        }\n      }\n    }\n\n    this.requestSaveToDisk();\n    return true;\n  }\n\n  markAccountVerificationRequired(accountIndex: number, reason?: string, verifyUrl?: string): boolean {\n    const account = this.accounts[accountIndex];\n    if (!account) {\n      return false;\n    }\n\n    account.verificationRequired = true;\n    account.verificationRequiredAt = nowMs();\n    account.verificationRequiredReason = reason?.trim() || undefined;\n\n    const normalizedVerifyUrl = verifyUrl?.trim();\n    if (normalizedVerifyUrl) {\n      account.verificationUrl = normalizedVerifyUrl;\n    }\n\n    if (account.enabled !== false) {\n      this.setAccountEnabled(accountIndex, false);\n    } else {\n      this.requestSaveToDisk();\n    }\n\n    return true;\n  }\n\n  clearAccountVerificationRequired(accountIndex: number, enableAccount = false): boolean {\n    const account = this.accounts[accountIndex];\n    if (!account) {\n      return false;\n    }\n\n    const wasVerificationRequired = account.verificationRequired === true;\n    const hadMetadata = (\n      account.verificationRequiredAt !== undefined ||\n      account.verificationRequiredReason !== undefined ||\n      account.verificationUrl !== undefined\n    );\n\n    account.verificationRequired = false;\n    account.verificationRequiredAt = undefined;\n    account.verificationRequiredReason = undefined;\n    account.verificationUrl = undefined;\n\n    if (enableAccount && wasVerificationRequired && account.enabled === false) {\n      this.setAccountEnabled(accountIndex, true);\n    } else if (wasVerificationRequired || hadMetadata) {\n      this.requestSaveToDisk();\n    }\n\n    return true;\n  }\n\n  removeAccountByIndex(accountIndex: number): boolean {\n    if (accountIndex < 0 || accountIndex >= this.accounts.length) {\n      return false;\n    }\n    const account = this.accounts[accountIndex];\n    if (!account) {\n      return false;\n    }\n    return this.removeAccount(account);\n  }\n\n  removeAccount(account: ManagedAccount): boolean {\n    const idx = this.accounts.indexOf(account);\n    if (idx < 0) {\n      return false;\n    }\n\n    this.accounts.splice(idx, 1);\n    this.accounts.forEach((acc, index) => {\n      acc.index = index;\n    });\n\n    if (this.accounts.length === 0) {\n      this.cursor = 0;\n      this.currentAccountIndexByFamily.claude = -1;\n      this.currentAccountIndexByFamily.gemini = -1;\n      return true;\n    }\n\n    if (this.cursor > idx) {\n      this.cursor -= 1;\n    }\n    this.cursor = this.cursor % this.accounts.length;\n\n    for (const family of [\"claude\", \"gemini\"] as ModelFamily[]) {\n      if (this.currentAccountIndexByFamily[family] > idx) {\n        this.currentAccountIndexByFamily[family] -= 1;\n      }\n      if (this.currentAccountIndexByFamily[family] >= this.accounts.length) {\n        this.currentAccountIndexByFamily[family] = -1;\n      }\n    }\n\n    return true;\n  }\n\n  updateFromAuth(account: ManagedAccount, auth: OAuthAuthDetails): void {\n    const parts = parseRefreshParts(auth.refresh);\n    // Preserve existing projectId/managedProjectId if not in the new parts\n    account.parts = {\n      ...parts,\n      projectId: parts.projectId ?? account.parts.projectId,\n      managedProjectId: parts.managedProjectId ?? account.parts.managedProjectId,\n    };\n    account.access = auth.access;\n    account.expires = auth.expires;\n  }\n\n  toAuthDetails(account: ManagedAccount): OAuthAuthDetails {\n    return {\n      type: \"oauth\",\n      refresh: formatRefreshParts(account.parts),\n      access: account.access,\n      expires: account.expires,\n    };\n  }\n\n  getMinWaitTimeForFamily(\n    family: ModelFamily,\n    model?: string | null,\n    headerStyle?: HeaderStyle,\n    strict?: boolean,\n  ): number {\n    const available = this.accounts.filter((a) => {\n      clearExpiredRateLimits(a);\n      return a.enabled !== false && (strict && headerStyle\n        ? !isRateLimitedForHeaderStyle(a, family, headerStyle, model)\n        : !isRateLimitedForFamily(a, family, model));\n    });\n    if (available.length > 0) {\n      return 0;\n    }\n\n    const waitTimes: number[] = [];\n    for (const a of this.accounts) {\n      if (family === \"claude\") {\n        const t = a.rateLimitResetTimes.claude;\n        if (t !== undefined) waitTimes.push(Math.max(0, t - nowMs()));\n      } else if (strict && headerStyle) {\n        const key = getQuotaKey(family, headerStyle, model);\n        const t = a.rateLimitResetTimes[key];\n        if (t !== undefined) waitTimes.push(Math.max(0, t - nowMs()));\n      } else {\n        // For Gemini, account becomes available when EITHER pool expires for this model/family\n        const antigravityKey = getQuotaKey(family, \"antigravity\", model);\n        const cliKey = getQuotaKey(family, \"gemini-cli\", model);\n\n        const t1 = a.rateLimitResetTimes[antigravityKey];\n        const t2 = a.rateLimitResetTimes[cliKey];\n        \n        const accountWait = Math.min(\n          t1 !== undefined ? Math.max(0, t1 - nowMs()) : Infinity,\n          t2 !== undefined ? Math.max(0, t2 - nowMs()) : Infinity\n        );\n        if (accountWait !== Infinity) waitTimes.push(accountWait);\n      }\n    }\n\n    return waitTimes.length > 0 ? Math.min(...waitTimes) : 0;\n  }\n\n  getAccounts(): ManagedAccount[] {\n    return [...this.accounts];\n  }\n\n  async saveToDisk(): Promise<void> {\n    const claudeIndex = Math.max(0, this.currentAccountIndexByFamily.claude);\n    const geminiIndex = Math.max(0, this.currentAccountIndexByFamily.gemini);\n    \n    const storage: AccountStorageV4 = {\n      version: 4,\n      accounts: this.accounts.map((a) => ({\n        email: a.email,\n        refreshToken: a.parts.refreshToken,\n        projectId: a.parts.projectId,\n        managedProjectId: a.parts.managedProjectId,\n        addedAt: a.addedAt,\n        lastUsed: a.lastUsed,\n        enabled: a.enabled,\n        lastSwitchReason: a.lastSwitchReason,\n        rateLimitResetTimes: Object.keys(a.rateLimitResetTimes).length > 0 ? a.rateLimitResetTimes : undefined,\n        coolingDownUntil: a.coolingDownUntil,\n        cooldownReason: a.cooldownReason,\n        fingerprint: a.fingerprint,\n        fingerprintHistory: a.fingerprintHistory?.length ? a.fingerprintHistory : undefined,\n        cachedQuota: a.cachedQuota && Object.keys(a.cachedQuota).length > 0 ? a.cachedQuota : undefined,\n        cachedQuotaUpdatedAt: a.cachedQuotaUpdatedAt,\n        verificationRequired: a.verificationRequired,\n        verificationRequiredAt: a.verificationRequiredAt,\n        verificationRequiredReason: a.verificationRequiredReason,\n        verificationUrl: a.verificationUrl,\n      })),\n      activeIndex: claudeIndex,\n      activeIndexByFamily: {\n        claude: claudeIndex,\n        gemini: geminiIndex,\n      },\n    };\n\n    await saveAccounts(storage);\n  }\n\n  requestSaveToDisk(): void {\n    if (this.savePending) {\n      return;\n    }\n    this.savePending = true;\n    this.saveTimeout = setTimeout(() => {\n      void this.executeSave();\n    }, 1000);\n  }\n\n  async flushSaveToDisk(): Promise<void> {\n    if (!this.savePending) {\n      return;\n    }\n    return new Promise<void>((resolve) => {\n      this.savePromiseResolvers.push(resolve);\n    });\n  }\n\n  private async executeSave(): Promise<void> {\n    this.savePending = false;\n    this.saveTimeout = null;\n    \n    try {\n      await this.saveToDisk();\n    } catch {\n      // best-effort persistence; avoid unhandled rejection from timer-driven saves\n    } finally {\n      const resolvers = this.savePromiseResolvers;\n      this.savePromiseResolvers = [];\n      for (const resolve of resolvers) {\n        resolve();\n      }\n    }\n  }\n\n  // ========== Fingerprint Management ==========\n\n  /**\n   * Regenerate fingerprint for an account, saving the old one to history.\n   * @param accountIndex - Index of the account to regenerate fingerprint for\n   * @returns The new fingerprint, or null if account not found\n   */\n  regenerateAccountFingerprint(accountIndex: number): Fingerprint | null {\n    const account = this.accounts[accountIndex];\n    if (!account) return null;\n    \n    // Save current fingerprint to history if it exists\n    if (account.fingerprint) {\n      const historyEntry: FingerprintVersion = {\n        fingerprint: account.fingerprint,\n        timestamp: nowMs(),\n        reason: 'regenerated',\n      };\n      \n      if (!account.fingerprintHistory) {\n        account.fingerprintHistory = [];\n      }\n      \n      // Add to beginning of history (most recent first)\n      account.fingerprintHistory.unshift(historyEntry);\n      \n      // Trim to max history size\n      if (account.fingerprintHistory.length > MAX_FINGERPRINT_HISTORY) {\n        account.fingerprintHistory = account.fingerprintHistory.slice(0, MAX_FINGERPRINT_HISTORY);\n      }\n    }\n\n    // Generate and assign new fingerprint\n    account.fingerprint = generateFingerprint();\n    this.requestSaveToDisk();\n    \n    return account.fingerprint;\n  }\n\n  /**\n   * Restore a fingerprint from history for an account.\n   * @param accountIndex - Index of the account\n   * @param historyIndex - Index in the fingerprint history to restore from (0 = most recent)\n   * @returns The restored fingerprint, or null if account/history not found\n   */\n  restoreAccountFingerprint(accountIndex: number, historyIndex: number): Fingerprint | null {\n    const account = this.accounts[accountIndex];\n    if (!account) return null;\n\n    const history = account.fingerprintHistory;\n    if (!history || historyIndex < 0 || historyIndex >= history.length) {\n      return null;\n    }\n    \n    // Capture the fingerprint to restore BEFORE modifying history\n    const fingerprintToRestore = history[historyIndex]!.fingerprint;\n    \n    // Save current fingerprint to history before restoring (if it exists)\n    if (account.fingerprint) {\n      const historyEntry: FingerprintVersion = {\n        fingerprint: account.fingerprint,\n        timestamp: nowMs(),\n        reason: 'restored',\n      };\n      \n      account.fingerprintHistory!.unshift(historyEntry);\n      \n      // Trim to max history size\n      if (account.fingerprintHistory!.length > MAX_FINGERPRINT_HISTORY) {\n        account.fingerprintHistory = account.fingerprintHistory!.slice(0, MAX_FINGERPRINT_HISTORY);\n      }\n    }\n\n    // Restore the fingerprint\n    account.fingerprint = { ...fingerprintToRestore, createdAt: nowMs() };\n    \n    this.requestSaveToDisk();\n    \n    return account.fingerprint;\n  }\n\n  /**\n   * Get fingerprint history for an account.\n   * @param accountIndex - Index of the account\n   * @returns Array of fingerprint versions, or empty array if not found\n   */\n  getAccountFingerprintHistory(accountIndex: number): FingerprintVersion[] {\n    const account = this.accounts[accountIndex];\n    if (!account || !account.fingerprintHistory) {\n      return [];\n    }\n    return [...account.fingerprintHistory];\n  }\n\n  updateQuotaCache(accountIndex: number, quotaGroups: Partial<Record<QuotaGroup, QuotaGroupSummary>>): void {\n    const account = this.accounts[accountIndex];\n    if (account) {\n      account.cachedQuota = quotaGroups;\n      account.cachedQuotaUpdatedAt = nowMs();\n    }\n  }\n\n  isAccountOverSoftQuota(account: ManagedAccount, family: ModelFamily, thresholdPercent: number, cacheTtlMs: number, model?: string | null): boolean {\n    return isOverSoftQuotaThreshold(account, family, thresholdPercent, cacheTtlMs, model);\n  }\n\n  getAccountsForQuotaCheck(): AccountMetadataV3[] {\n    return this.accounts.map((a) => ({\n      email: a.email,\n      refreshToken: a.parts.refreshToken,\n      projectId: a.parts.projectId,\n      managedProjectId: a.parts.managedProjectId,\n      addedAt: a.addedAt,\n      lastUsed: a.lastUsed,\n      enabled: a.enabled,\n    }));\n  }\n\n  getOldestQuotaCacheAge(): number | null {\n    let oldest: number | null = null;\n    for (const acc of this.accounts) {\n      if (acc.enabled === false) continue;\n      if (acc.cachedQuotaUpdatedAt == null) return null;\n      const age = nowMs() - acc.cachedQuotaUpdatedAt;\n      if (oldest === null || age > oldest) oldest = age;\n    }\n    return oldest;\n  }\n\n  areAllAccountsOverSoftQuota(family: ModelFamily, thresholdPercent: number, cacheTtlMs: number, model?: string | null): boolean {\n    if (thresholdPercent >= 100) return false;\n    const enabled = this.accounts.filter(a => a.enabled !== false);\n    if (enabled.length === 0) return false;\n    return enabled.every(a => isOverSoftQuotaThreshold(a, family, thresholdPercent, cacheTtlMs, model));\n  }\n\n  /**\n   * Get minimum wait time until any account's soft quota resets.\n   * Returns 0 if any account is available (not over threshold).\n   * Returns the minimum resetTime across all over-threshold accounts.\n   * Returns null if no resetTime data is available.\n   */\n  getMinWaitTimeForSoftQuota(\n    family: ModelFamily,\n    thresholdPercent: number,\n    cacheTtlMs: number,\n    model?: string | null\n  ): number | null {\n    if (thresholdPercent >= 100) return 0;\n    \n    const enabled = this.accounts.filter(a => a.enabled !== false);\n    if (enabled.length === 0) return null;\n    \n    // If any account is available (not over threshold), no wait needed\n    const available = enabled.filter(a => !isOverSoftQuotaThreshold(a, family, thresholdPercent, cacheTtlMs, model));\n    if (available.length > 0) return 0;\n    \n    // All accounts are over threshold - find earliest reset time\n    // For gemini family, we MUST have the model to distinguish pro vs flash quotas.\n    // Fail-open (return null = no wait info) if model is missing to avoid blocking on wrong quota.\n    if (!model && family !== \"claude\") return null;\n    const quotaGroup = resolveQuotaGroup(family, model);\n    const now = nowMs();\n    const waitTimes: number[] = [];\n    \n    for (const acc of enabled) {\n      const groupData = acc.cachedQuota?.[quotaGroup];\n      if (groupData?.resetTime) {\n        const resetTimestamp = Date.parse(groupData.resetTime);\n        if (Number.isFinite(resetTimestamp)) {\n          waitTimes.push(Math.max(0, resetTimestamp - now));\n        }\n      }\n    }\n    \n    if (waitTimes.length === 0) return null;\n    const minWait = Math.min(...waitTimes);\n    // Treat 0 as stale cache (resetTime in the past) → fail-open to avoid spin loop\n    return minWait === 0 ? null : minWait;\n  }\n}\n"
  },
  {
    "path": "src/plugin/antigravity-first-fallback.test.ts",
    "content": "import { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport { AccountManager, type ModelFamily, type HeaderStyle } from \"./accounts\";\nimport type { AccountStorageV4 } from \"./storage\";\n\n/**\n * Test: Antigravity-first fallback logic\n * \n * Requirement: Exhaust Antigravity across ALL accounts before falling back to Gemini CLI\n * \n * Scenario:\n * - Account 0: antigravity rate-limited, gemini-cli available\n * - Account 1: antigravity available\n * \n * Expected: Switch to Account 1 (use antigravity), NOT fall back to gemini-cli on Account 0\n */\ndescribe(\"Antigravity-first fallback\", () => {\n  beforeEach(() => {\n    vi.useRealTimers();\n  });\n\n  describe(\"hasOtherAccountWithAntigravityAvailable\", () => {\n    it(\"returns true when another account has antigravity available\", () => {\n      const stored: AccountStorageV4 = {\n        version: 4,\n        accounts: [\n          { refreshToken: \"r1\", projectId: \"p1\", addedAt: 1, lastUsed: 0 },\n          { refreshToken: \"r2\", projectId: \"p2\", addedAt: 1, lastUsed: 0 },\n        ],\n        activeIndex: 0,\n      };\n\n      const manager = new AccountManager(undefined, stored);\n      const accounts = manager.getAccounts();\n      \n      // Mark account 0's antigravity as rate-limited\n      manager.markRateLimited(accounts[0]!, 60000, \"gemini\", \"antigravity\");\n\n      // Account 1 should have antigravity available\n      const hasOther = manager.hasOtherAccountWithAntigravityAvailable(\n        accounts[0]!.index,\n        \"gemini\",\n        null\n      );\n\n      expect(hasOther).toBe(true);\n    });\n\n    it(\"returns false when all other accounts are also rate-limited for antigravity\", () => {\n      const stored: AccountStorageV4 = {\n        version: 4,\n        accounts: [\n          { refreshToken: \"r1\", projectId: \"p1\", addedAt: 1, lastUsed: 0 },\n          { refreshToken: \"r2\", projectId: \"p2\", addedAt: 1, lastUsed: 0 },\n        ],\n        activeIndex: 0,\n      };\n\n      const manager = new AccountManager(undefined, stored);\n      const accounts = manager.getAccounts();\n      \n      // Mark both accounts' antigravity as rate-limited\n      manager.markRateLimited(accounts[0]!, 60000, \"gemini\", \"antigravity\");\n      manager.markRateLimited(accounts[1]!, 60000, \"gemini\", \"antigravity\");\n\n      const hasOther = manager.hasOtherAccountWithAntigravityAvailable(\n        accounts[0]!.index,\n        \"gemini\",\n        null\n      );\n\n      expect(hasOther).toBe(false);\n    });\n\n    it(\"skips disabled accounts\", () => {\n      const stored: AccountStorageV4 = {\n        version: 4,\n        accounts: [\n          { refreshToken: \"r1\", projectId: \"p1\", addedAt: 1, lastUsed: 0 },\n          { refreshToken: \"r2\", projectId: \"p2\", addedAt: 1, lastUsed: 0, enabled: false },\n        ],\n        activeIndex: 0,\n      };\n\n      const manager = new AccountManager(undefined, stored);\n      const accounts = manager.getAccounts();\n      \n      // Mark account 0's antigravity as rate-limited\n      manager.markRateLimited(accounts[0]!, 60000, \"gemini\", \"antigravity\");\n\n      // Account 1 is disabled, so should return false\n      const hasOther = manager.hasOtherAccountWithAntigravityAvailable(\n        accounts[0]!.index,\n        \"gemini\",\n        null\n      );\n\n      expect(hasOther).toBe(false);\n    });\n\n    it(\"skips cooling down accounts\", () => {\n      const stored: AccountStorageV4 = {\n        version: 4,\n        accounts: [\n          { refreshToken: \"r1\", projectId: \"p1\", addedAt: 1, lastUsed: 0 },\n          { refreshToken: \"r2\", projectId: \"p2\", addedAt: 1, lastUsed: 0 },\n        ],\n        activeIndex: 0,\n      };\n\n      const manager = new AccountManager(undefined, stored);\n      const accounts = manager.getAccounts();\n      \n      // Mark account 0's antigravity as rate-limited\n      manager.markRateLimited(accounts[0]!, 60000, \"gemini\", \"antigravity\");\n      // Mark account 1 as cooling down\n      manager.markAccountCoolingDown(accounts[1]!, 60000, \"auth-failure\");\n\n      const hasOther = manager.hasOtherAccountWithAntigravityAvailable(\n        accounts[0]!.index,\n        \"gemini\",\n        null\n      );\n\n      expect(hasOther).toBe(false);\n    });\n\n    it(\"works with model-specific rate limits\", () => {\n      const stored: AccountStorageV4 = {\n        version: 4,\n        accounts: [\n          { refreshToken: \"r1\", projectId: \"p1\", addedAt: 1, lastUsed: 0 },\n          { refreshToken: \"r2\", projectId: \"p2\", addedAt: 1, lastUsed: 0 },\n        ],\n        activeIndex: 0,\n      };\n\n      const manager = new AccountManager(undefined, stored);\n      const accounts = manager.getAccounts();\n      \n      // Mark account 0's antigravity as rate-limited for gemini-3-pro\n      manager.markRateLimited(accounts[0]!, 60000, \"gemini\", \"antigravity\", \"gemini-3-pro\");\n\n      // Account 1 should have antigravity available for gemini-3-pro\n      const hasOther = manager.hasOtherAccountWithAntigravityAvailable(\n        accounts[0]!.index,\n        \"gemini\",\n        \"gemini-3-pro\"\n      );\n\n      expect(hasOther).toBe(true);\n    });\n\n    it(\"returns false for Claude family (no gemini-cli fallback)\", () => {\n      const stored: AccountStorageV4 = {\n        version: 4,\n        accounts: [\n          { refreshToken: \"r1\", projectId: \"p1\", addedAt: 1, lastUsed: 0 },\n          { refreshToken: \"r2\", projectId: \"p2\", addedAt: 1, lastUsed: 0 },\n        ],\n        activeIndex: 0,\n      };\n\n      const manager = new AccountManager(undefined, stored);\n\n      // For Claude, this method should always return false\n      // (Claude has no gemini-cli fallback, only antigravity)\n      const hasOther = manager.hasOtherAccountWithAntigravityAvailable(\n        0,\n        \"claude\",\n        null\n      );\n\n      expect(hasOther).toBe(false);\n    });\n  });\n\n  describe(\"Pre-check fallback logic\", () => {\n    it(\"should switch to account with antigravity rather than fall back to gemini-cli\", () => {\n      const stored: AccountStorageV4 = {\n        version: 4,\n        accounts: [\n          { refreshToken: \"r1\", projectId: \"p1\", addedAt: 1, lastUsed: 0 },\n          { refreshToken: \"r2\", projectId: \"p2\", addedAt: 1, lastUsed: 0 },\n        ],\n        activeIndex: 0,\n        activeIndexByFamily: { claude: 0, gemini: 0 },\n      };\n\n      const manager = new AccountManager(undefined, stored);\n      const accounts = manager.getAccounts();\n      \n      // Account 0's antigravity is rate-limited but gemini-cli is available\n      manager.markRateLimited(accounts[0]!, 60000, \"gemini\", \"antigravity\");\n      \n      // Account 1's antigravity is available\n      // (not rate-limited for antigravity)\n\n      // When requesting with antigravity headerStyle:\n      // Should switch to account 1 (which has antigravity), NOT fall back to gemini-cli\n      \n      const nextAccount = manager.getCurrentOrNextForFamily(\n        \"gemini\",\n        null,\n        \"sticky\",\n        \"antigravity\"\n      );\n\n      expect(nextAccount?.index).toBe(1);\n      expect(manager.isRateLimitedForHeaderStyle(nextAccount!, \"gemini\", \"antigravity\")).toBe(false);\n    });\n\n    it(\"should only fall back to gemini-cli when ALL accounts exhausted antigravity\", () => {\n      const stored: AccountStorageV4 = {\n        version: 4,\n        accounts: [\n          { refreshToken: \"r1\", projectId: \"p1\", addedAt: 1, lastUsed: 0 },\n          { refreshToken: \"r2\", projectId: \"p2\", addedAt: 1, lastUsed: 0 },\n        ],\n        activeIndex: 0,\n        activeIndexByFamily: { claude: 0, gemini: 0 },\n      };\n\n      const manager = new AccountManager(undefined, stored);\n      const accounts = manager.getAccounts();\n      \n      // Both accounts' antigravity are rate-limited\n      manager.markRateLimited(accounts[0]!, 60000, \"gemini\", \"antigravity\");\n      manager.markRateLimited(accounts[1]!, 60000, \"gemini\", \"antigravity\");\n\n      // Verify no account has antigravity available\n      expect(manager.hasOtherAccountWithAntigravityAvailable(0, \"gemini\", null)).toBe(false);\n      expect(manager.hasOtherAccountWithAntigravityAvailable(1, \"gemini\", null)).toBe(false);\n\n      // Account 0's gemini-cli should still be available for fallback\n      expect(manager.isRateLimitedForHeaderStyle(accounts[0]!, \"gemini\", \"gemini-cli\")).toBe(false);\n      expect(manager.getAvailableHeaderStyle(accounts[0]!, \"gemini\")).toBe(\"gemini-cli\");\n    });\n  });\n});\n"
  },
  {
    "path": "src/plugin/auth.test.ts",
    "content": "import { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport { isOAuthAuth, parseRefreshParts, formatRefreshParts, accessTokenExpired } from \"./auth\";\nimport type { OAuthAuthDetails, ApiKeyAuthDetails } from \"./types\";\n\ndescribe(\"isOAuthAuth\", () => {\n  it(\"returns true for oauth auth type\", () => {\n    const auth: OAuthAuthDetails = {\n      type: \"oauth\",\n      refresh: \"token|project\",\n      access: \"access-token\",\n      expires: Date.now() + 3600000,\n    };\n    expect(isOAuthAuth(auth)).toBe(true);\n  });\n\n  it(\"returns false for api_key auth type\", () => {\n    const auth: ApiKeyAuthDetails = {\n      type: \"api_key\",\n      key: \"some-api-key\",\n    };\n    expect(isOAuthAuth(auth)).toBe(false);\n  });\n});\n\ndescribe(\"parseRefreshParts\", () => {\n  it(\"parses refresh token with all parts\", () => {\n    const result = parseRefreshParts(\"refreshToken|projectId|managedProjectId\");\n    expect(result).toEqual({\n      refreshToken: \"refreshToken\",\n      projectId: \"projectId\",\n      managedProjectId: \"managedProjectId\",\n    });\n  });\n\n  it(\"parses refresh token with only refresh and project\", () => {\n    const result = parseRefreshParts(\"refreshToken|projectId\");\n    expect(result).toEqual({\n      refreshToken: \"refreshToken\",\n      projectId: \"projectId\",\n      managedProjectId: undefined,\n    });\n  });\n\n  it(\"parses refresh token with only refresh token\", () => {\n    const result = parseRefreshParts(\"refreshToken\");\n    expect(result).toEqual({\n      refreshToken: \"refreshToken\",\n      projectId: undefined,\n      managedProjectId: undefined,\n    });\n  });\n\n  it(\"handles empty string\", () => {\n    const result = parseRefreshParts(\"\");\n    expect(result).toEqual({\n      refreshToken: \"\",\n      projectId: undefined,\n      managedProjectId: undefined,\n    });\n  });\n\n  it(\"handles empty parts\", () => {\n    const result = parseRefreshParts(\"refreshToken||managedProjectId\");\n    expect(result).toEqual({\n      refreshToken: \"refreshToken\",\n      projectId: undefined,\n      managedProjectId: \"managedProjectId\",\n    });\n  });\n\n  it(\"handles undefined/null-like input\", () => {\n    // @ts-expect-error - testing edge case\n    const result = parseRefreshParts(undefined);\n    expect(result).toEqual({\n      refreshToken: \"\",\n      projectId: undefined,\n      managedProjectId: undefined,\n    });\n  });\n});\n\ndescribe(\"formatRefreshParts\", () => {\n  it(\"formats all parts\", () => {\n    const result = formatRefreshParts({\n      refreshToken: \"refreshToken\",\n      projectId: \"projectId\",\n      managedProjectId: \"managedProjectId\",\n    });\n    expect(result).toBe(\"refreshToken|projectId|managedProjectId\");\n  });\n\n  it(\"formats without managed project id\", () => {\n    const result = formatRefreshParts({\n      refreshToken: \"refreshToken\",\n      projectId: \"projectId\",\n    });\n    expect(result).toBe(\"refreshToken|projectId\");\n  });\n\n  it(\"formats without project id but with managed project id\", () => {\n    const result = formatRefreshParts({\n      refreshToken: \"refreshToken\",\n      managedProjectId: \"managedProjectId\",\n    });\n    expect(result).toBe(\"refreshToken||managedProjectId\");\n  });\n\n  it(\"formats with only refresh token\", () => {\n    const result = formatRefreshParts({\n      refreshToken: \"refreshToken\",\n    });\n    expect(result).toBe(\"refreshToken|\");\n  });\n\n  it(\"round-trips correctly with parseRefreshParts\", () => {\n    const original = {\n      refreshToken: \"rt123\",\n      projectId: \"proj456\",\n      managedProjectId: \"managed789\",\n    };\n    const formatted = formatRefreshParts(original);\n    const parsed = parseRefreshParts(formatted);\n    expect(parsed).toEqual(original);\n  });\n});\n\ndescribe(\"accessTokenExpired\", () => {\n  beforeEach(() => {\n    vi.useRealTimers();\n  });\n\n  it(\"returns true when access token is missing\", () => {\n    const auth: OAuthAuthDetails = {\n      type: \"oauth\",\n      refresh: \"token\",\n      access: undefined,\n      expires: Date.now() + 3600000,\n    };\n    expect(accessTokenExpired(auth)).toBe(true);\n  });\n\n  it(\"returns true when expires is missing\", () => {\n    const auth: OAuthAuthDetails = {\n      type: \"oauth\",\n      refresh: \"token\",\n      access: \"access-token\",\n      expires: undefined,\n    };\n    expect(accessTokenExpired(auth)).toBe(true);\n  });\n\n  it(\"returns true when token is expired\", () => {\n    const auth: OAuthAuthDetails = {\n      type: \"oauth\",\n      refresh: \"token\",\n      access: \"access-token\",\n      expires: Date.now() - 1000, // expired 1 second ago\n    };\n    expect(accessTokenExpired(auth)).toBe(true);\n  });\n\n  it(\"returns true when token expires within buffer period (60 seconds)\", () => {\n    const auth: OAuthAuthDetails = {\n      type: \"oauth\",\n      refresh: \"token\",\n      access: \"access-token\",\n      expires: Date.now() + 30000, // expires in 30 seconds (within 60s buffer)\n    };\n    expect(accessTokenExpired(auth)).toBe(true);\n  });\n\n  it(\"returns false when token is valid and outside buffer period\", () => {\n    const auth: OAuthAuthDetails = {\n      type: \"oauth\",\n      refresh: \"token\",\n      access: \"access-token\",\n      expires: Date.now() + 120000, // expires in 2 minutes\n    };\n    expect(accessTokenExpired(auth)).toBe(false);\n  });\n\n  it(\"returns false when token expires exactly at buffer boundary\", () => {\n    vi.useFakeTimers();\n    vi.setSystemTime(new Date(0));\n\n    const auth: OAuthAuthDetails = {\n      type: \"oauth\",\n      refresh: \"token\",\n      access: \"access-token\",\n      expires: 60001, // expires 60001ms from now, just outside 60s buffer\n    };\n    expect(accessTokenExpired(auth)).toBe(false);\n  });\n});\n"
  },
  {
    "path": "src/plugin/auth.ts",
    "content": "import type { AuthDetails, OAuthAuthDetails, RefreshParts } from \"./types\";\n\nconst ACCESS_TOKEN_EXPIRY_BUFFER_MS = 60 * 1000;\n\nexport function isOAuthAuth(auth: AuthDetails): auth is OAuthAuthDetails {\n  return auth.type === \"oauth\";\n}\n\n/**\n * Splits a packed refresh string into its constituent refresh token and project IDs.\n */\nexport function parseRefreshParts(refresh: string): RefreshParts {\n  const [refreshToken = \"\", projectId = \"\", managedProjectId = \"\"] = (refresh ?? \"\").split(\"|\");\n  return {\n    refreshToken,\n    projectId: projectId || undefined,\n    managedProjectId: managedProjectId || undefined,\n  };\n}\n\n/**\n * Serializes refresh token parts into the stored string format.\n */\nexport function formatRefreshParts(parts: RefreshParts): string {\n  const projectSegment = parts.projectId ?? \"\";\n  const base = `${parts.refreshToken}|${projectSegment}`;\n  return parts.managedProjectId ? `${base}|${parts.managedProjectId}` : base;\n}\n\n/**\n * Determines whether an access token is expired or missing, with buffer for clock skew.\n */\nexport function accessTokenExpired(auth: OAuthAuthDetails): boolean {\n  if (!auth.access || typeof auth.expires !== \"number\") {\n    return true;\n  }\n  return auth.expires <= Date.now() + ACCESS_TOKEN_EXPIRY_BUFFER_MS;\n}\n\n/**\n * Calculates absolute expiry timestamp based on a duration.\n * @param requestTimeMs The local time when the request was initiated\n * @param expiresInSeconds The duration returned by the server\n */\nexport function calculateTokenExpiry(requestTimeMs: number, expiresInSeconds: unknown): number {\n  const seconds = typeof expiresInSeconds === \"number\" ? expiresInSeconds : 3600;\n  // Safety check for bad data - if it's not a positive number, treat as immediately expired\n  if (isNaN(seconds) || seconds <= 0) {\n    return requestTimeMs;\n  }\n  return requestTimeMs + seconds * 1000;\n}\n"
  },
  {
    "path": "src/plugin/cache/index.ts",
    "content": "/**\n * Cache module for opencode-antigravity-auth plugin.\n */\n\nexport {\n  SignatureCache,\n  createSignatureCache,\n} from \"./signature-cache\";\n"
  },
  {
    "path": "src/plugin/cache/signature-cache.ts",
    "content": "/**\n * Signature cache for persisting thinking block signatures to disk.\n * \n * Features (based on LLM-API-Key-Proxy's ProviderCache):\n * - Dual-TTL system: short memory TTL, longer disk TTL\n * - Background disk persistence with batched writes\n * - Atomic writes with temp file + move pattern\n * - Automatic cleanup of expired entries\n * \n * Cache key format: `${sessionId}:${modelId}`\n */\n\nimport { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync, unlinkSync } from \"node:fs\";\nimport { join, dirname } from \"node:path\";\nimport { homedir } from \"node:os\";\nimport { tmpdir } from \"node:os\";\nimport type { SignatureCacheConfig } from \"../config\";\nimport { ensureGitignoreSync } from \"../storage\";\n\n// =============================================================================\n// Types\n// =============================================================================\n\ninterface CacheEntry {\n  value: string;\n  timestamp: number;\n  /** Full thinking text content (optional, for recovery) */\n  thinkingText?: string;\n  /** Preview of the thinking text for debugging */\n  textPreview?: string;\n  /** Tool call IDs associated with this thinking block */\n  toolIds?: string[];\n}\n\ninterface CacheData {\n  version: \"1.0\";\n  memory_ttl_seconds: number;\n  disk_ttl_seconds: number;\n  entries: Record<string, CacheEntry>;\n  statistics: {\n    memory_hits: number;\n    disk_hits: number;\n    misses: number;\n    writes: number;\n    last_write: number;\n  };\n}\n\ninterface CacheStats {\n  memoryHits: number;\n  diskHits: number;\n  misses: number;\n  writes: number;\n  memoryEntries: number;\n  dirty: boolean;\n  diskEnabled: boolean;\n}\n\n/**\n * Full thinking content with signature (for recovery)\n */\nexport interface ThinkingCacheData {\n  text: string;\n  signature: string;\n  toolIds?: string[];\n}\n\n// =============================================================================\n// Path Utilities\n// =============================================================================\n\nfunction getConfigDir(): string {\n  const platform = process.platform;\n  if (platform === \"win32\") {\n    return join(process.env.APPDATA || join(homedir(), \"AppData\", \"Roaming\"), \"opencode\");\n  }\n  const xdgConfig = process.env.XDG_CONFIG_HOME || join(homedir(), \".config\");\n  return join(xdgConfig, \"opencode\");\n}\n\nfunction getCacheFilePath(): string {\n  return join(getConfigDir(), \"antigravity-signature-cache.json\");\n}\n\n// =============================================================================\n// Signature Cache Class\n// =============================================================================\n\nexport class SignatureCache {\n  // In-memory cache: key -> entry with signature and optional thinking text\n  private cache: Map<string, CacheEntry> = new Map();\n  \n  // Configuration\n  private memoryTtlMs: number;\n  private diskTtlMs: number;\n  private writeIntervalMs: number;\n  private cacheFilePath: string;\n  private enabled: boolean;\n  \n  // State\n  private dirty: boolean = false;\n  private writeTimer: ReturnType<typeof setInterval> | null = null;\n  private cleanupTimer: ReturnType<typeof setInterval> | null = null;\n  \n  // Statistics\n  private stats = {\n    memoryHits: 0,\n    diskHits: 0,\n    misses: 0,\n    writes: 0,\n  };\n\n  constructor(config: SignatureCacheConfig) {\n    this.enabled = config.enabled;\n    this.memoryTtlMs = config.memory_ttl_seconds * 1000;\n    this.diskTtlMs = config.disk_ttl_seconds * 1000;\n    this.writeIntervalMs = config.write_interval_seconds * 1000;\n    this.cacheFilePath = getCacheFilePath();\n\n    if (this.enabled) {\n      this.loadFromDisk();\n      this.startBackgroundTasks();\n    }\n  }\n\n  // ===========================================================================\n  // Public API\n  // ===========================================================================\n\n  /**\n   * Generate a cache key from sessionId and modelId.\n   */\n  static makeKey(sessionId: string, modelId: string): string {\n    return `${sessionId}:${modelId}`;\n  }\n\n  /**\n   * Store a signature in the cache.\n   */\n  store(key: string, signature: string): void {\n    if (!this.enabled) return;\n\n    this.cache.set(key, {\n      value: signature,\n      timestamp: Date.now(),\n    });\n    this.dirty = true;\n  }\n\n  /**\n   * Retrieve a signature from the cache.\n   * Returns null if not found or expired.\n   */\n  retrieve(key: string): string | null {\n    if (!this.enabled) return null;\n\n    const entry = this.cache.get(key);\n    if (entry) {\n      const age = Date.now() - entry.timestamp;\n      if (age <= this.memoryTtlMs) {\n        this.stats.memoryHits++;\n        return entry.value;\n      }\n      // Expired from memory, remove it\n      this.cache.delete(key);\n    }\n\n    this.stats.misses++;\n    return null;\n  }\n\n  /**\n   * Check if a key exists in the cache (without updating stats).\n   */\n  has(key: string): boolean {\n    if (!this.enabled) return false;\n\n    const entry = this.cache.get(key);\n    if (!entry) return false;\n\n    const age = Date.now() - entry.timestamp;\n    return age <= this.memoryTtlMs;\n  }\n\n  // ===========================================================================\n  // Full Thinking Cache (ported from LLM-API-Key-Proxy)\n  // ===========================================================================\n\n  /**\n   * Store full thinking content with signature.\n   * This enables recovery even after thinking text is stripped by compaction.\n   * \n   * Port of LLM-API-Key-Proxy's _cache_thinking()\n   */\n  storeThinking(\n    key: string,\n    thinkingText: string,\n    signature: string,\n    toolIds?: string[],\n  ): void {\n    if (!this.enabled || !thinkingText || !signature) return;\n\n    this.cache.set(key, {\n      value: signature,\n      timestamp: Date.now(),\n      thinkingText,\n      textPreview: thinkingText.slice(0, 100),\n      toolIds,\n    });\n    this.dirty = true;\n  }\n\n  /**\n   * Retrieve full thinking content by key.\n   * Returns null if not found or expired.\n   */\n  retrieveThinking(key: string): ThinkingCacheData | null {\n    if (!this.enabled) return null;\n\n    const entry = this.cache.get(key);\n    if (!entry || !entry.thinkingText) return null;\n\n    const age = Date.now() - entry.timestamp;\n    if (age > this.memoryTtlMs) {\n      this.cache.delete(key);\n      return null;\n    }\n\n    this.stats.memoryHits++;\n    return {\n      text: entry.thinkingText,\n      signature: entry.value,\n      toolIds: entry.toolIds,\n    };\n  }\n\n  /**\n   * Check if full thinking content exists for a key.\n   */\n  hasThinking(key: string): boolean {\n    if (!this.enabled) return false;\n\n    const entry = this.cache.get(key);\n    if (!entry || !entry.thinkingText) return false;\n\n    const age = Date.now() - entry.timestamp;\n    return age <= this.memoryTtlMs;\n  }\n\n  /**\n   * Get cache statistics.\n   */\n  getStats(): CacheStats {\n    return {\n      ...this.stats,\n      memoryEntries: this.cache.size,\n      dirty: this.dirty,\n      diskEnabled: this.enabled,\n    };\n  }\n\n  /**\n   * Manually trigger a disk save.\n   */\n  async flush(): Promise<boolean> {\n    if (!this.enabled) return true;\n    return this.saveToDisk();\n  }\n\n  /**\n   * Graceful shutdown: stop timers and flush to disk.\n   */\n  shutdown(): void {\n    if (this.writeTimer) {\n      clearInterval(this.writeTimer);\n      this.writeTimer = null;\n    }\n    if (this.cleanupTimer) {\n      clearInterval(this.cleanupTimer);\n      this.cleanupTimer = null;\n    }\n\n    if (this.dirty && this.enabled) {\n      this.saveToDisk();\n    }\n  }\n\n  // ===========================================================================\n  // Disk Operations\n  // ===========================================================================\n\n  /**\n   * Load cache from disk file with TTL validation.\n   */\n  private loadFromDisk(): void {\n    try {\n      if (!existsSync(this.cacheFilePath)) {\n        return;\n      }\n\n      const content = readFileSync(this.cacheFilePath, \"utf-8\");\n      const data = JSON.parse(content) as CacheData;\n\n      if (data.version !== \"1.0\") {\n        // Version mismatch - silently start fresh\n        return;\n      }\n\n      const now = Date.now();\n      let loaded = 0;\n      let expired = 0;\n\n      for (const [key, entry] of Object.entries(data.entries)) {\n        const age = now - entry.timestamp;\n        if (age <= this.diskTtlMs) {\n          this.cache.set(key, {\n            value: entry.value,\n            timestamp: entry.timestamp,\n          });\n          loaded++;\n        } else {\n          expired++;\n        }\n      }\n\n      // Silently load - no console output\n    } catch {\n      // Silently start fresh on any error (corruption, file not found, etc.)\n    }\n  }\n\n  /**\n   * Save cache to disk with atomic write pattern.\n   * Merges with existing disk entries that haven't expired.\n   */\n  private saveToDisk(): boolean {\n    try {\n      // Ensure directory exists\n      const dir = dirname(this.cacheFilePath);\n      if (!existsSync(dir)) {\n        mkdirSync(dir, { recursive: true });\n      }\n\n      ensureGitignoreSync(dir);\n\n      const now = Date.now();\n\n      // Step 1: Load existing disk entries (if any)\n      let existingEntries: Record<string, CacheEntry> = {};\n      if (existsSync(this.cacheFilePath)) {\n        try {\n          const content = readFileSync(this.cacheFilePath, \"utf-8\");\n          const data = JSON.parse(content) as CacheData;\n          existingEntries = data.entries || {};\n        } catch {\n          // Start fresh if corrupted\n        }\n      }\n\n      // Step 2: Filter existing disk entries by disk_ttl\n      const validDiskEntries: Record<string, CacheEntry> = {};\n      for (const [key, entry] of Object.entries(existingEntries)) {\n        const age = now - entry.timestamp;\n        if (age <= this.diskTtlMs) {\n          validDiskEntries[key] = entry;\n        }\n      }\n\n      // Step 3: Merge - memory entries take precedence\n      const mergedEntries: Record<string, CacheEntry> = { ...validDiskEntries };\n      for (const [key, entry] of this.cache.entries()) {\n        mergedEntries[key] = {\n          value: entry.value,\n          timestamp: entry.timestamp,\n        };\n      }\n\n      // Step 4: Build cache data\n      const cacheData: CacheData = {\n        version: \"1.0\",\n        memory_ttl_seconds: this.memoryTtlMs / 1000,\n        disk_ttl_seconds: this.diskTtlMs / 1000,\n        entries: mergedEntries,\n        statistics: {\n          memory_hits: this.stats.memoryHits,\n          disk_hits: this.stats.diskHits,\n          misses: this.stats.misses,\n          writes: this.stats.writes + 1,\n          last_write: now,\n        },\n      };\n\n      // Step 5: Atomic write (temp file + rename)\n      const tmpPath = join(tmpdir(), `antigravity-cache-${Date.now()}-${Math.random().toString(36).slice(2)}.tmp`);\n      writeFileSync(tmpPath, JSON.stringify(cacheData, null, 2), \"utf-8\");\n\n      try {\n        renameSync(tmpPath, this.cacheFilePath);\n      } catch {\n        // On Windows, rename across volumes may fail\n        // Fall back to copy + delete\n        writeFileSync(this.cacheFilePath, readFileSync(tmpPath));\n        try {\n          unlinkSync(tmpPath);\n        } catch {\n          // Ignore cleanup errors\n        }\n      }\n\n      this.stats.writes++;\n      this.dirty = false;\n      return true;\n    } catch {\n      // Silently fail - disk cache is optional\n      return false;\n    }\n  }\n\n  // ===========================================================================\n  // Background Tasks\n  // ===========================================================================\n\n  /**\n   * Start background write and cleanup timers.\n   */\n  private startBackgroundTasks(): void {\n    // Periodic disk writes\n    this.writeTimer = setInterval(() => {\n      if (this.dirty) {\n        this.saveToDisk();\n      }\n    }, this.writeIntervalMs);\n\n    // Periodic memory cleanup (every 30 minutes)\n    this.cleanupTimer = setInterval(() => {\n      this.cleanupExpired();\n    }, 30 * 60 * 1000);\n  }\n\n  /**\n   * Remove expired entries from memory.\n   */\n  private cleanupExpired(): void {\n    const now = Date.now();\n    let cleaned = 0;\n\n    for (const [key, entry] of this.cache.entries()) {\n      const age = now - entry.timestamp;\n      if (age > this.memoryTtlMs) {\n        this.cache.delete(key);\n        cleaned++;\n      }\n    }\n\n    // Silently clean - no console output\n  }\n}\n\n// =============================================================================\n// Factory Function\n// =============================================================================\n\n/**\n * Create a signature cache with the given configuration.\n * Returns null if caching is disabled.\n */\nexport function createSignatureCache(config: SignatureCacheConfig | undefined): SignatureCache | null {\n  if (!config || !config.enabled) {\n    return null;\n  }\n\n  return new SignatureCache(config);\n}\n"
  },
  {
    "path": "src/plugin/cache.test.ts",
    "content": "import { afterEach, beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport {\n  resolveCachedAuth,\n  storeCachedAuth,\n  clearCachedAuth,\n  cacheSignature,\n  getCachedSignature,\n  clearSignatureCache,\n} from \"./cache\";\nimport type { OAuthAuthDetails } from \"./types\";\n\nfunction createAuth(overrides: Partial<OAuthAuthDetails> = {}): OAuthAuthDetails {\n  return {\n    type: \"oauth\",\n    refresh: \"refresh-token|project-id\",\n    access: \"access-token\",\n    expires: Date.now() + 3600000,\n    ...overrides,\n  };\n}\n\ndescribe(\"Auth Cache\", () => {\n  beforeEach(() => {\n    vi.useRealTimers();\n    clearCachedAuth();\n  });\n\n  afterEach(() => {\n    clearCachedAuth();\n  });\n\n  describe(\"resolveCachedAuth\", () => {\n    it(\"returns input auth when no cache exists and caches it\", () => {\n      const auth = createAuth();\n      const result = resolveCachedAuth(auth);\n      expect(result).toEqual(auth);\n    });\n\n    it(\"returns input auth when refresh key is empty\", () => {\n      const auth = createAuth({ refresh: \"\" });\n      const result = resolveCachedAuth(auth);\n      expect(result).toEqual(auth);\n    });\n\n    it(\"returns input auth when it has valid (unexpired) access token\", () => {\n      const oldAuth = createAuth({ access: \"old-access\", expires: Date.now() + 3600000 });\n      resolveCachedAuth(oldAuth); // cache it\n\n      const newAuth = createAuth({ access: \"new-access\", expires: Date.now() + 7200000 });\n      const result = resolveCachedAuth(newAuth);\n      expect(result.access).toBe(\"new-access\");\n    });\n\n    it(\"returns cached auth when input auth is expired but cached is valid\", () => {\n      vi.useFakeTimers();\n      vi.setSystemTime(new Date(0));\n\n      const validAuth = createAuth({\n        access: \"valid-access\",\n        expires: 3600000, // expires at t=3600000\n      });\n      resolveCachedAuth(validAuth); // cache it\n\n      // Now create an expired auth with the same refresh token\n      const expiredAuth = createAuth({\n        access: \"expired-access\",\n        expires: 30000, // expires within buffer (60s)\n      });\n\n      const result = resolveCachedAuth(expiredAuth);\n      expect(result.access).toBe(\"valid-access\");\n    });\n\n    it(\"returns input auth when both are expired (updates cache)\", () => {\n      vi.useFakeTimers();\n      vi.setSystemTime(new Date(0));\n\n      const expiredCached = createAuth({\n        access: \"cached-expired\",\n        expires: 30000, // expired within buffer\n      });\n      resolveCachedAuth(expiredCached);\n\n      const expiredNew = createAuth({\n        access: \"new-expired\",\n        expires: 20000, // also expired within buffer\n      });\n\n      const result = resolveCachedAuth(expiredNew);\n      expect(result.access).toBe(\"new-expired\");\n    });\n  });\n\n  describe(\"storeCachedAuth\", () => {\n    it(\"stores auth in cache\", () => {\n      const auth = createAuth({ access: \"stored-access\" });\n      storeCachedAuth(auth);\n\n      const expiredAuth = createAuth({ access: \"expired\", expires: Date.now() - 1000 });\n      const result = resolveCachedAuth(expiredAuth);\n      expect(result.access).toBe(\"stored-access\");\n    });\n\n    it(\"does nothing when refresh key is empty\", () => {\n      const auth = createAuth({ refresh: \"\", access: \"no-key-access\" });\n      storeCachedAuth(auth);\n\n      // Should not be retrievable since key was empty\n      const testAuth = createAuth({ refresh: \"\", access: \"test\" });\n      const result = resolveCachedAuth(testAuth);\n      expect(result.access).toBe(\"test\"); // returns the input, not cached\n    });\n\n    it(\"does nothing when refresh key is whitespace only\", () => {\n      const auth = createAuth({ refresh: \"   \", access: \"whitespace-access\" });\n      storeCachedAuth(auth);\n\n      const testAuth = createAuth({ refresh: \"   \", access: \"test\" });\n      const result = resolveCachedAuth(testAuth);\n      expect(result.access).toBe(\"test\");\n    });\n  });\n\n  describe(\"clearCachedAuth\", () => {\n    it(\"clears all cache when no argument provided\", () => {\n      storeCachedAuth(createAuth({ refresh: \"token1|p\", access: \"access1\" }));\n      storeCachedAuth(createAuth({ refresh: \"token2|p\", access: \"access2\" }));\n\n      clearCachedAuth();\n\n      const auth1 = createAuth({ refresh: \"token1|p\", access: \"new1\" });\n      const auth2 = createAuth({ refresh: \"token2|p\", access: \"new2\" });\n\n      expect(resolveCachedAuth(auth1).access).toBe(\"new1\");\n      expect(resolveCachedAuth(auth2).access).toBe(\"new2\");\n    });\n\n    it(\"clears specific refresh token from cache\", () => {\n      storeCachedAuth(createAuth({ refresh: \"token1|p\", access: \"access1\" }));\n      storeCachedAuth(createAuth({ refresh: \"token2|p\", access: \"access2\" }));\n\n      clearCachedAuth(\"token1|p\");\n\n      // token1 should be cleared\n      const expiredAuth1 = createAuth({ refresh: \"token1|p\", access: \"new1\", expires: Date.now() - 1000 });\n      expect(resolveCachedAuth(expiredAuth1).access).toBe(\"new1\");\n\n      // token2 should still be cached\n      const expiredAuth2 = createAuth({ refresh: \"token2|p\", access: \"new2\", expires: Date.now() - 1000 });\n      expect(resolveCachedAuth(expiredAuth2).access).toBe(\"access2\");\n    });\n  });\n});\n\ndescribe(\"Signature Cache\", () => {\n  beforeEach(() => {\n    vi.useRealTimers();\n    clearSignatureCache();\n  });\n\n  afterEach(() => {\n    clearSignatureCache();\n  });\n\n  describe(\"cacheSignature\", () => {\n    it(\"caches a signature for session and text\", () => {\n      cacheSignature(\"session1\", \"thinking text\", \"sig123\");\n      const result = getCachedSignature(\"session1\", \"thinking text\");\n      expect(result).toBe(\"sig123\");\n    });\n\n    it(\"does nothing when sessionId is empty\", () => {\n      cacheSignature(\"\", \"text\", \"sig\");\n      expect(getCachedSignature(\"\", \"text\")).toBeUndefined();\n    });\n\n    it(\"does nothing when text is empty\", () => {\n      cacheSignature(\"session\", \"\", \"sig\");\n      expect(getCachedSignature(\"session\", \"\")).toBeUndefined();\n    });\n\n    it(\"does nothing when signature is empty\", () => {\n      cacheSignature(\"session\", \"text\", \"\");\n      expect(getCachedSignature(\"session\", \"text\")).toBeUndefined();\n    });\n\n    it(\"stores multiple signatures per session\", () => {\n      cacheSignature(\"session1\", \"text1\", \"sig1\");\n      cacheSignature(\"session1\", \"text2\", \"sig2\");\n\n      expect(getCachedSignature(\"session1\", \"text1\")).toBe(\"sig1\");\n      expect(getCachedSignature(\"session1\", \"text2\")).toBe(\"sig2\");\n    });\n\n    it(\"stores signatures for different sessions independently\", () => {\n      cacheSignature(\"session1\", \"text\", \"sig1\");\n      cacheSignature(\"session2\", \"text\", \"sig2\");\n\n      expect(getCachedSignature(\"session1\", \"text\")).toBe(\"sig1\");\n      expect(getCachedSignature(\"session2\", \"text\")).toBe(\"sig2\");\n    });\n  });\n\n  describe(\"getCachedSignature\", () => {\n    it(\"returns undefined when session not found\", () => {\n      expect(getCachedSignature(\"unknown\", \"text\")).toBeUndefined();\n    });\n\n    it(\"returns undefined when text not found in session\", () => {\n      cacheSignature(\"session\", \"known-text\", \"sig\");\n      expect(getCachedSignature(\"session\", \"unknown-text\")).toBeUndefined();\n    });\n\n    it(\"returns undefined when sessionId is empty\", () => {\n      expect(getCachedSignature(\"\", \"text\")).toBeUndefined();\n    });\n\n    it(\"returns undefined when text is empty\", () => {\n      expect(getCachedSignature(\"session\", \"\")).toBeUndefined();\n    });\n\n    it(\"returns undefined when signature is expired\", () => {\n      vi.useFakeTimers();\n      vi.setSystemTime(new Date(0));\n\n      cacheSignature(\"session\", \"text\", \"sig\");\n\n      // Advance time past TTL (1 hour = 3600000ms)\n      vi.setSystemTime(new Date(3600001));\n\n      expect(getCachedSignature(\"session\", \"text\")).toBeUndefined();\n    });\n\n    it(\"returns signature when not expired\", () => {\n      vi.useFakeTimers();\n      vi.setSystemTime(new Date(0));\n\n      cacheSignature(\"session\", \"text\", \"sig\");\n\n      // Advance time but stay within TTL\n      vi.setSystemTime(new Date(3599999));\n\n      expect(getCachedSignature(\"session\", \"text\")).toBe(\"sig\");\n    });\n  });\n\n  describe(\"clearSignatureCache\", () => {\n    it(\"clears all signature cache when no argument provided\", () => {\n      cacheSignature(\"session1\", \"text\", \"sig1\");\n      cacheSignature(\"session2\", \"text\", \"sig2\");\n\n      clearSignatureCache();\n\n      expect(getCachedSignature(\"session1\", \"text\")).toBeUndefined();\n      expect(getCachedSignature(\"session2\", \"text\")).toBeUndefined();\n    });\n\n    it(\"clears specific session from cache\", () => {\n      cacheSignature(\"session1\", \"text\", \"sig1\");\n      cacheSignature(\"session2\", \"text\", \"sig2\");\n\n      clearSignatureCache(\"session1\");\n\n      expect(getCachedSignature(\"session1\", \"text\")).toBeUndefined();\n      expect(getCachedSignature(\"session2\", \"text\")).toBe(\"sig2\");\n    });\n  });\n\n  describe(\"cache eviction\", () => {\n    it(\"evicts entries when at capacity\", () => {\n      vi.useFakeTimers();\n      vi.setSystemTime(new Date(0));\n\n      // Fill cache with 100 entries (MAX_ENTRIES_PER_SESSION)\n      for (let i = 0; i < 100; i++) {\n        vi.setSystemTime(new Date(i * 1000)); // stagger timestamps\n        cacheSignature(\"session\", `text-${i}`, `sig-${i}`);\n      }\n\n      // Reset time to check entries\n      vi.setSystemTime(new Date(100 * 1000));\n\n      // Adding one more should trigger eviction\n      cacheSignature(\"session\", \"new-text\", \"new-sig\");\n\n      // New entry should exist\n      expect(getCachedSignature(\"session\", \"new-text\")).toBe(\"new-sig\");\n\n      // Some old entries should have been evicted (oldest 25%)\n      // Entry at index 0 (timestamp 0) should be evicted\n      expect(getCachedSignature(\"session\", \"text-0\")).toBeUndefined();\n    });\n  });\n});\n"
  },
  {
    "path": "src/plugin/cache.ts",
    "content": "import { accessTokenExpired } from \"./auth\";\nimport type { OAuthAuthDetails } from \"./types\";\nimport { createHash } from \"node:crypto\";\n\nconst authCache = new Map<string, OAuthAuthDetails>();\n\n/**\n * Produces a stable cache key from a refresh token string.\n */\nfunction normalizeRefreshKey(refresh?: string): string | undefined {\n  const key = refresh?.trim();\n  return key ? key : undefined;\n}\n\n/**\n * Returns a cached auth snapshot when available, favoring unexpired tokens.\n */\nexport function resolveCachedAuth(auth: OAuthAuthDetails): OAuthAuthDetails {\n  const key = normalizeRefreshKey(auth.refresh);\n  if (!key) {\n    return auth;\n  }\n\n  const cached = authCache.get(key);\n  if (!cached) {\n    authCache.set(key, auth);\n    return auth;\n  }\n\n  if (!accessTokenExpired(auth)) {\n    authCache.set(key, auth);\n    return auth;\n  }\n\n  if (!accessTokenExpired(cached)) {\n    return cached;\n  }\n\n  authCache.set(key, auth);\n  return auth;\n}\n\n/**\n * Stores the latest auth snapshot keyed by refresh token.\n */\nexport function storeCachedAuth(auth: OAuthAuthDetails): void {\n  const key = normalizeRefreshKey(auth.refresh);\n  if (!key) {\n    return;\n  }\n  authCache.set(key, auth);\n}\n\n/**\n * Clears cached auth globally or for a specific refresh token.\n */\nexport function clearCachedAuth(refresh?: string): void {\n  if (!refresh) {\n    authCache.clear();\n    return;\n  }\n  const key = normalizeRefreshKey(refresh);\n  if (key) {\n    authCache.delete(key);\n  }\n}\n\n// ============================================================================\n// Thinking Signature Cache (for Claude multi-turn conversations)\n// ============================================================================\n\nimport { SignatureCache, createSignatureCache } from \"./cache/signature-cache\";\nimport type { SignatureCacheConfig } from \"./config\";\n\ninterface SignatureEntry {\n  signature: string;\n  timestamp: number;\n}\n\n// Map: sessionId -> Map<textHash, SignatureEntry>\nconst signatureCache = new Map<string, Map<string, SignatureEntry>>();\n\n// Cache entries expire after 1 hour\nconst SIGNATURE_CACHE_TTL_MS = 60 * 60 * 1000;\n\n// Maximum entries per session to prevent memory bloat\nconst MAX_ENTRIES_PER_SESSION = 100;\n\n// 16 hex chars = 64-bit key space; keeps memory bounded while making collisions extremely unlikely.\nconst SIGNATURE_TEXT_HASH_HEX_LEN = 16;\n\n// Disk cache instance (initialized via initDiskSignatureCache)\nlet diskCache: SignatureCache | null = null;\n\n/**\n * Initialize the disk-based signature cache.\n * Call this from plugin initialization when keep_thinking is enabled.\n */\nexport function initDiskSignatureCache(config: SignatureCacheConfig | undefined): SignatureCache | null {\n  diskCache = createSignatureCache(config);\n  return diskCache;\n}\n\n/**\n * Get the disk cache instance (for testing/debugging).\n */\nexport function getDiskSignatureCache(): SignatureCache | null {\n  return diskCache;\n}\n\n/**\n * Hashes text content into a stable, Unicode-safe key.\n *\n * Uses SHA-256 over UTF-8 bytes and truncates to keep memory usage bounded.\n */\nfunction hashText(text: string): string {\n  return createHash(\"sha256\").update(text, \"utf8\").digest(\"hex\").slice(0, SIGNATURE_TEXT_HASH_HEX_LEN);\n}\n\n/**\n * Create a disk cache key from sessionId and textHash.\n */\nfunction makeDiskKey(sessionId: string, textHash: string): string {\n  return `${sessionId}:${textHash}`;\n}\n\n/**\n * Caches a thinking signature for a given session and text.\n * Used for Claude models that require signed thinking blocks in multi-turn conversations.\n * Also writes to disk cache if enabled.\n */\nexport function cacheSignature(sessionId: string, text: string, signature: string): void {\n  if (!sessionId || !text || !signature) return;\n\n  const textHash = hashText(text);\n\n  // Write to memory cache\n  let sessionMemCache = signatureCache.get(sessionId);\n  if (!sessionMemCache) {\n    sessionMemCache = new Map();\n    signatureCache.set(sessionId, sessionMemCache);\n  }\n\n  // Evict old entries if we're at capacity\n  if (sessionMemCache.size >= MAX_ENTRIES_PER_SESSION) {\n    const now = Date.now();\n    for (const [key, entry] of sessionMemCache.entries()) {\n      if (now - entry.timestamp > SIGNATURE_CACHE_TTL_MS) {\n        sessionMemCache.delete(key);\n      }\n    }\n    // If still at capacity, remove oldest entries\n    if (sessionMemCache.size >= MAX_ENTRIES_PER_SESSION) {\n      const entries = Array.from(sessionMemCache.entries())\n        .sort((a, b) => a[1].timestamp - b[1].timestamp);\n      const toRemove = entries.slice(0, Math.floor(MAX_ENTRIES_PER_SESSION / 4));\n      for (const [key] of toRemove) {\n        sessionMemCache.delete(key);\n      }\n    }\n  }\n\n  sessionMemCache.set(textHash, { signature, timestamp: Date.now() });\n\n  // Write to disk cache if enabled\n  if (diskCache) {\n    const diskKey = makeDiskKey(sessionId, textHash);\n    diskCache.store(diskKey, signature);\n  }\n}\n\n/**\n * Retrieves a cached signature for a given session and text.\n * Checks memory first, then falls back to disk cache.\n * Returns undefined if not found or expired.\n */\nexport function getCachedSignature(sessionId: string, text: string): string | undefined {\n  if (!sessionId || !text) return undefined;\n\n  const textHash = hashText(text);\n\n  // Check memory cache first\n  const sessionMemCache = signatureCache.get(sessionId);\n  if (sessionMemCache) {\n    const entry = sessionMemCache.get(textHash);\n    if (entry) {\n      // Check if expired\n      if (Date.now() - entry.timestamp > SIGNATURE_CACHE_TTL_MS) {\n        sessionMemCache.delete(textHash);\n      } else {\n        return entry.signature;\n      }\n    }\n  }\n\n  // Fall back to disk cache\n  if (diskCache) {\n    const diskKey = makeDiskKey(sessionId, textHash);\n    const diskValue = diskCache.retrieve(diskKey);\n    if (diskValue) {\n      // Promote to memory cache for faster subsequent access\n      let memCache = signatureCache.get(sessionId);\n      if (!memCache) {\n        memCache = new Map();\n        signatureCache.set(sessionId, memCache);\n      }\n      memCache.set(textHash, { signature: diskValue, timestamp: Date.now() });\n      return diskValue;\n    }\n  }\n\n  return undefined;\n}\n\n/**\n * Clears signature cache for a specific session or all sessions.\n * Also clears from disk cache if enabled.\n */\nexport function clearSignatureCache(sessionId?: string): void {\n  if (sessionId) {\n    signatureCache.delete(sessionId);\n    // Note: We don't clear individual sessions from disk cache to avoid\n    // expensive iteration. Disk cache entries will expire naturally.\n  } else {\n    signatureCache.clear();\n    // For full clear, we could clear disk cache, but leaving it for now\n    // since entries have TTL and will expire naturally.\n  }\n}\n\n// ============================================================================\n// Disk-Persistent Signature Cache (re-export from cache/ folder)\n// ============================================================================\n\n// Re-export SignatureCache class and factory for direct use\nexport { SignatureCache, createSignatureCache } from \"./cache/signature-cache\";\nexport type { SignatureCacheConfig } from \"./config\";\n"
  },
  {
    "path": "src/plugin/cli.ts",
    "content": "import { createInterface } from \"node:readline/promises\";\nimport { stdin as input, stdout as output } from \"node:process\";\nimport {\n  showAuthMenu,\n  showAccountDetails,\n  isTTY,\n  type AccountInfo,\n  type AccountStatus,\n} from \"./ui/auth-menu\";\nimport { updateOpencodeConfig } from \"./config/updater\";\n\nexport async function promptProjectId(): Promise<string> {\n  const rl = createInterface({ input, output });\n  try {\n    const answer = await rl.question(\"Project ID (leave blank to use your default project): \");\n    return answer.trim();\n  } finally {\n    rl.close();\n  }\n}\n\nexport async function promptAddAnotherAccount(currentCount: number): Promise<boolean> {\n  const rl = createInterface({ input, output });\n  try {\n    const answer = await rl.question(`Add another account? (${currentCount} added) (y/n): `);\n    const normalized = answer.trim().toLowerCase();\n    return normalized === \"y\" || normalized === \"yes\";\n  } finally {\n    rl.close();\n  }\n}\n\nexport type LoginMode = \"add\" | \"fresh\" | \"manage\" | \"check\" | \"verify\" | \"verify-all\" | \"cancel\";\n\nexport interface ExistingAccountInfo {\n  email?: string;\n  index: number;\n  addedAt?: number;\n  lastUsed?: number;\n  status?: AccountStatus;\n  isCurrentAccount?: boolean;\n  enabled?: boolean;\n}\n\nexport interface LoginMenuResult {\n  mode: LoginMode;\n  deleteAccountIndex?: number;\n  refreshAccountIndex?: number;\n  toggleAccountIndex?: number;\n  verifyAccountIndex?: number;\n  verifyAll?: boolean;\n  deleteAll?: boolean;\n}\n\nasync function promptLoginModeFallback(existingAccounts: ExistingAccountInfo[]): Promise<LoginMenuResult> {\n  const rl = createInterface({ input, output });\n  try {\n    console.log(`\\n${existingAccounts.length} account(s) saved:`);\n    for (const acc of existingAccounts) {\n      const label = acc.email || `Account ${acc.index + 1}`;\n      console.log(`  ${acc.index + 1}. ${label}`);\n    }\n    console.log(\"\");\n\n    while (true) {\n      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]: \");\n      const normalized = answer.trim().toLowerCase();\n\n      if (normalized === \"a\" || normalized === \"add\") {\n        return { mode: \"add\" };\n      }\n      if (normalized === \"f\" || normalized === \"fresh\") {\n        return { mode: \"fresh\" };\n      }\n      if (normalized === \"c\" || normalized === \"check\") {\n        return { mode: \"check\" };\n      }\n      if (normalized === \"v\" || normalized === \"verify\") {\n        return { mode: \"verify\" };\n      }\n      if (normalized === \"va\" || normalized === \"verify-all\" || normalized === \"all\") {\n        return { mode: \"verify-all\", verifyAll: true };\n      }\n\n      console.log(\"Please enter 'a', 'f', 'c', 'v', or 'va'.\");\n    }\n  } finally {\n    rl.close();\n  }\n}\n\nexport async function promptLoginMode(existingAccounts: ExistingAccountInfo[]): Promise<LoginMenuResult> {\n  if (!isTTY()) {\n    return promptLoginModeFallback(existingAccounts);\n  }\n\n  const accounts: AccountInfo[] = existingAccounts.map(acc => ({\n    email: acc.email,\n    index: acc.index,\n    addedAt: acc.addedAt,\n    lastUsed: acc.lastUsed,\n    status: acc.status,\n    isCurrentAccount: acc.isCurrentAccount,\n    enabled: acc.enabled,\n  }));\n\n  console.log(\"\");\n\n  while (true) {\n    const action = await showAuthMenu(accounts);\n\n    switch (action.type) {\n      case \"add\":\n        return { mode: \"add\" };\n\n      case \"check\":\n        return { mode: \"check\" };\n\n      case \"verify\":\n        return { mode: \"verify\" };\n\n      case \"verify-all\":\n        return { mode: \"verify-all\", verifyAll: true };\n\n      case \"select-account\": {\n        const accountAction = await showAccountDetails(action.account);\n        if (accountAction === \"delete\") {\n          return { mode: \"add\", deleteAccountIndex: action.account.index };\n        }\n        if (accountAction === \"refresh\") {\n          return { mode: \"add\", refreshAccountIndex: action.account.index };\n        }\n        if (accountAction === \"toggle\") {\n          return { mode: \"manage\", toggleAccountIndex: action.account.index };\n        }\n        if (accountAction === \"verify\") {\n          return { mode: \"verify\", verifyAccountIndex: action.account.index };\n        }\n        continue;\n      }\n\n      case \"delete-all\":\n        return { mode: \"fresh\", deleteAll: true };\n\n      case \"configure-models\": {\n        const result = await updateOpencodeConfig();\n        if (result.success) {\n          console.log(`\\n✓ Models configured in ${result.configPath}\\n`);\n        } else {\n          console.log(`\\n✗ Failed to configure models: ${result.error}\\n`);\n        }\n        continue;\n      }\n\n      case \"cancel\":\n        return { mode: \"cancel\" };\n    }\n  }\n}\n\nexport { isTTY } from \"./ui/auth-menu\";\nexport type { AccountStatus } from \"./ui/auth-menu\";\n"
  },
  {
    "path": "src/plugin/config/index.ts",
    "content": "/**\n * Configuration module for opencode-antigravity-auth plugin.\n * \n * @example\n * ```typescript\n * import { loadConfig, type AntigravityConfig } from \"./config\";\n * \n * const config = loadConfig(directory);\n * if (config.session_recovery) {\n *   // Enable session recovery\n * }\n * ```\n */\n\nexport {\n  AntigravityConfigSchema,\n  SignatureCacheConfigSchema,\n  DEFAULT_CONFIG,\n  type AntigravityConfig,\n  type SignatureCacheConfig,\n} from \"./schema\";\n\nexport {\n  loadConfig,\n  getUserConfigPath,\n  getProjectConfigPath,\n  getDefaultLogsDir,\n  configExists,\n  initRuntimeConfig,\n  getKeepThinking,\n} from \"./loader\";\n"
  },
  {
    "path": "src/plugin/config/loader.ts",
    "content": "/**\n * Configuration loader for opencode-antigravity-auth plugin.\n * \n * Loads config from files.\n * Priority (lowest to highest):\n * 1. Schema defaults\n * 2. User config file\n * 3. Project config file\n */\n\nimport { existsSync, readFileSync } from \"node:fs\";\nimport { join } from \"node:path\";\nimport { homedir } from \"node:os\";\nimport { AntigravityConfigSchema, DEFAULT_CONFIG, type AntigravityConfig } from \"./schema\";\nimport { createLogger } from \"../logger\";\n\nconst log = createLogger(\"config\");\n\n// =============================================================================\n// Path Utilities\n// =============================================================================\n\n/**\n * Get the config directory path, with the following precedence:\n * 1. OPENCODE_CONFIG_DIR env var (if set)\n * 2. ~/.config/opencode (all platforms, including Windows)\n */\nfunction getConfigDir(): string {\n  // 1. Check for explicit override via env var\n  if (process.env.OPENCODE_CONFIG_DIR) {\n    return process.env.OPENCODE_CONFIG_DIR;\n  }\n\n  // 2. Use ~/.config/opencode on all platforms (including Windows)\n  const xdgConfig = process.env.XDG_CONFIG_HOME || join(homedir(), \".config\");\n  return join(xdgConfig, \"opencode\");\n}\n\n/**\n * Get the user-level config file path.\n */\nexport function getUserConfigPath(): string {\n  return join(getConfigDir(), \"antigravity.json\");\n}\n\n/**\n * Get the project-level config file path.\n */\nexport function getProjectConfigPath(directory: string): string {\n  return join(directory, \".opencode\", \"antigravity.json\");\n}\n\n// =============================================================================\n// Config Loading\n// =============================================================================\n\n/**\n * Load and parse a config file, returning null if not found or invalid.\n */\nfunction loadConfigFile(path: string): Partial<AntigravityConfig> | null {\n  try {\n    if (!existsSync(path)) {\n      return null;\n    }\n\n    const content = readFileSync(path, \"utf-8\");\n    const rawConfig = JSON.parse(content);\n\n    // Validate with Zod (partial - we'll merge with defaults later)\n    const result = AntigravityConfigSchema.partial().safeParse(rawConfig);\n\n    if (!result.success) {\n      log.warn(\"Config validation error\", {\n        path,\n        issues: result.error.issues.map(i => `${i.path.join(\".\")}: ${i.message}`).join(\", \"),\n      });\n      return null;\n    }\n\n    return result.data;\n  } catch (error) {\n    if (error instanceof SyntaxError) {\n      log.warn(\"Invalid JSON in config file\", { path, error: error.message });\n    } else {\n      log.warn(\"Failed to load config file\", { path, error: String(error) });\n    }\n    return null;\n  }\n}\n\n/**\n * Deep merge two config objects, with override taking precedence.\n */\nfunction mergeConfigs(\n  base: AntigravityConfig,\n  override: Partial<AntigravityConfig>\n): AntigravityConfig {\n  return {\n    ...base,\n    ...override,\n    // Deep merge signature_cache if both exist\n    signature_cache: override.signature_cache\n      ? {\n          ...base.signature_cache,\n          ...override.signature_cache,\n        }\n      : base.signature_cache,\n  };\n}\n\n// =============================================================================\n// Main Loader\n// =============================================================================\n\n/**\n * Load the complete configuration.\n * \n * @param directory - The project directory (for project-level config)\n * @returns Fully resolved configuration\n */\nexport function loadConfig(directory: string): AntigravityConfig {\n  // Start with defaults\n  let config: AntigravityConfig = { ...DEFAULT_CONFIG };\n\n  // Load user config file (if exists)\n  const userConfigPath = getUserConfigPath();\n  const userConfig = loadConfigFile(userConfigPath);\n  if (userConfig) {\n    config = mergeConfigs(config, userConfig);\n  }\n\n  // Load project config file (if exists) - overrides user config\n  const projectConfigPath = getProjectConfigPath(directory);\n  const projectConfig = loadConfigFile(projectConfigPath);\n  if (projectConfig) {\n    config = mergeConfigs(config, projectConfig);\n  }\n\n  return config;\n}\n\n/**\n * Check if a config file exists at the given path.\n */\nexport function configExists(path: string): boolean {\n  return existsSync(path);\n}\n\n/**\n * Get the default logs directory.\n */\nexport function getDefaultLogsDir(): string {\n  return join(getConfigDir(), \"antigravity-logs\");\n}\n\nlet runtimeConfig: AntigravityConfig | null = null;\n\nexport function initRuntimeConfig(config: AntigravityConfig): void {\n  runtimeConfig = config;\n}\n\nexport function getKeepThinking(): boolean {\n  return runtimeConfig?.keep_thinking ?? false;\n}\n"
  },
  {
    "path": "src/plugin/config/models.test.ts",
    "content": "import { describe, expect, it } from \"vitest\";\n\nimport { OPENCODE_MODEL_DEFINITIONS } from \"./models\";\n\nconst getModel = (name: string) => {\n  const model = OPENCODE_MODEL_DEFINITIONS[name];\n  if (!model) {\n    throw new Error(`Missing model definition for ${name}`);\n  }\n  return model;\n};\n\ndescribe(\"OPENCODE_MODEL_DEFINITIONS\", () => {\n  it(\"includes the full set of configured models\", () => {\n    const modelNames = Object.keys(OPENCODE_MODEL_DEFINITIONS).sort();\n\n    expect(modelNames).toEqual([\n      \"antigravity-claude-opus-4-6-thinking\",\n      \"antigravity-claude-sonnet-4-6\",\n      \"antigravity-gemini-3-flash\",\n      \"antigravity-gemini-3-pro\",\n      \"antigravity-gemini-3.1-pro\",\n      \"gemini-2.5-flash\",\n      \"gemini-2.5-pro\",\n      \"gemini-3-flash-preview\",\n      \"gemini-3-pro-preview\",\n      \"gemini-3.1-pro-preview\",\n      \"gemini-3.1-pro-preview-customtools\",\n    ]);\n  });\n\n  it(\"defines Gemini 3 variants for Antigravity models\", () => {\n    expect(getModel(\"antigravity-gemini-3-pro\").variants).toEqual({\n      low: { thinkingLevel: \"low\" },\n      high: { thinkingLevel: \"high\" },\n    });\n\n    expect(getModel(\"antigravity-gemini-3.1-pro\").variants).toEqual({\n      low: { thinkingLevel: \"low\" },\n      high: { thinkingLevel: \"high\" },\n    });\n\n    expect(getModel(\"antigravity-gemini-3-flash\").variants).toEqual({\n      minimal: { thinkingLevel: \"minimal\" },\n      low: { thinkingLevel: \"low\" },\n      medium: { thinkingLevel: \"medium\" },\n      high: { thinkingLevel: \"high\" },\n    });\n  });\n\n  it(\"defines thinking budget variants for Claude thinking models\", () => {\n    expect(getModel(\"antigravity-claude-opus-4-6-thinking\").variants).toEqual({\n      low: { thinkingConfig: { thinkingBudget: 8192 } },\n      max: { thinkingConfig: { thinkingBudget: 32768 } },\n    });\n  });\n});\n"
  },
  {
    "path": "src/plugin/config/models.ts",
    "content": "import type { ProviderModel } from \"../types\";\n\nexport type ModelThinkingLevel = \"minimal\" | \"low\" | \"medium\" | \"high\";\n\nexport interface ModelThinkingConfig {\n  thinkingBudget: number;\n}\n\nexport interface ModelVariant {\n  thinkingLevel?: ModelThinkingLevel;\n  thinkingConfig?: ModelThinkingConfig;\n}\n\nexport interface ModelLimit {\n  context: number;\n  output: number;\n}\n\nexport type ModelModality = \"text\" | \"image\" | \"pdf\";\n\nexport interface ModelModalities {\n  input: ModelModality[];\n  output: ModelModality[];\n}\n\nexport interface OpencodeModelDefinition extends ProviderModel {\n  name: string;\n  limit: ModelLimit;\n  modalities: ModelModalities;\n  variants?: Record<string, ModelVariant>;\n}\n\nexport type OpencodeModelDefinitions = Record<string, OpencodeModelDefinition>;\n\nconst DEFAULT_MODALITIES: ModelModalities = {\n  input: [\"text\", \"image\", \"pdf\"],\n  output: [\"text\"],\n};\n\nexport const OPENCODE_MODEL_DEFINITIONS: OpencodeModelDefinitions = {\n  \"antigravity-gemini-3-pro\": {\n    name: \"Gemini 3 Pro (Antigravity)\",\n    limit: { context: 1048576, output: 65535 },\n    modalities: DEFAULT_MODALITIES,\n    variants: {\n      low: { thinkingLevel: \"low\" },\n      high: { thinkingLevel: \"high\" },\n    },\n  },\n  \"antigravity-gemini-3.1-pro\": {\n    name: \"Gemini 3.1 Pro (Antigravity)\",\n    limit: { context: 1048576, output: 65535 },\n    modalities: DEFAULT_MODALITIES,\n    variants: {\n      low: { thinkingLevel: \"low\" },\n      high: { thinkingLevel: \"high\" },\n    },\n  },\n  \"antigravity-gemini-3-flash\": {\n    name: \"Gemini 3 Flash (Antigravity)\",\n    limit: { context: 1048576, output: 65536 },\n    modalities: DEFAULT_MODALITIES,\n    variants: {\n      minimal: { thinkingLevel: \"minimal\" },\n      low: { thinkingLevel: \"low\" },\n      medium: { thinkingLevel: \"medium\" },\n      high: { thinkingLevel: \"high\" },\n    },\n  },\n  \"antigravity-claude-sonnet-4-6\": {\n    name: \"Claude Sonnet 4.6 (Antigravity)\",\n    limit: { context: 200000, output: 64000 },\n    modalities: DEFAULT_MODALITIES,\n  },\n  \"antigravity-claude-opus-4-6-thinking\": {\n    name: \"Claude Opus 4.6 Thinking (Antigravity)\",\n    limit: { context: 200000, output: 64000 },\n    modalities: DEFAULT_MODALITIES,\n    variants: {\n      low: { thinkingConfig: { thinkingBudget: 8192 } },\n      max: { thinkingConfig: { thinkingBudget: 32768 } },\n    },\n  },\n  \"gemini-2.5-flash\": {\n    name: \"Gemini 2.5 Flash (Gemini CLI)\",\n    limit: { context: 1048576, output: 65536 },\n    modalities: DEFAULT_MODALITIES,\n  },\n  \"gemini-2.5-pro\": {\n    name: \"Gemini 2.5 Pro (Gemini CLI)\",\n    limit: { context: 1048576, output: 65536 },\n    modalities: DEFAULT_MODALITIES,\n  },\n  \"gemini-3-flash-preview\": {\n    name: \"Gemini 3 Flash Preview (Gemini CLI)\",\n    limit: { context: 1048576, output: 65536 },\n    modalities: DEFAULT_MODALITIES,\n  },\n  \"gemini-3-pro-preview\": {\n    name: \"Gemini 3 Pro Preview (Gemini CLI)\",\n    limit: { context: 1048576, output: 65535 },\n    modalities: DEFAULT_MODALITIES,\n  },\n  \"gemini-3.1-pro-preview\": {\n    name: \"Gemini 3.1 Pro Preview (Gemini CLI)\",\n    limit: { context: 1048576, output: 65535 },\n    modalities: DEFAULT_MODALITIES,\n  },\n  \"gemini-3.1-pro-preview-customtools\": {\n    name: \"Gemini 3.1 Pro Preview Custom Tools (Gemini CLI)\",\n    limit: { context: 1048576, output: 65535 },\n    modalities: DEFAULT_MODALITIES,\n  },\n};\n"
  },
  {
    "path": "src/plugin/config/schema.test.ts",
    "content": "import { readFileSync } from \"node:fs\";\nimport { describe, expect, it } from \"vitest\";\n\nimport { DEFAULT_CONFIG } from \"./schema\";\n\ndescribe(\"cli_first config\", () => {\n  it(\"includes cli_first default in DEFAULT_CONFIG\", () => {\n    expect(DEFAULT_CONFIG).toHaveProperty(\"cli_first\", false);\n  });\n\n  it(\"documents cli_first in the JSON schema\", () => {\n    const schemaPath = new URL(\"../../../assets/antigravity.schema.json\", import.meta.url);\n    const schema = JSON.parse(readFileSync(schemaPath, \"utf8\")) as {\n      properties?: Record<string, { type?: string; default?: unknown; description?: string }>;\n    };\n\n    const cliFirst = schema.properties?.cli_first;\n    expect(cliFirst).toBeDefined();\n    expect(cliFirst).toMatchObject({\n      type: \"boolean\",\n      default: false,\n    });\n    expect(typeof cliFirst?.description).toBe(\"string\");\n    expect(cliFirst?.description?.length ?? 0).toBeGreaterThan(0);\n  });\n});\n\ndescribe(\"claude_prompt_auto_caching config\", () => {\n  it(\"includes claude_prompt_auto_caching default in DEFAULT_CONFIG\", () => {\n    expect(DEFAULT_CONFIG).toHaveProperty(\"claude_prompt_auto_caching\", false);\n  });\n\n  it(\"documents claude_prompt_auto_caching in the JSON schema\", () => {\n    const schemaPath = new URL(\"../../../assets/antigravity.schema.json\", import.meta.url);\n    const schema = JSON.parse(readFileSync(schemaPath, \"utf8\")) as {\n      properties?: Record<string, { type?: string; default?: unknown; description?: string }>;\n    };\n\n    const claudePromptAutoCaching = schema.properties?.claude_prompt_auto_caching;\n    expect(claudePromptAutoCaching).toBeDefined();\n    expect(claudePromptAutoCaching).toMatchObject({\n      type: \"boolean\",\n      default: false,\n    });\n    expect(typeof claudePromptAutoCaching?.description).toBe(\"string\");\n    expect(claudePromptAutoCaching?.description?.length ?? 0).toBeGreaterThan(0);\n  });\n});\n"
  },
  {
    "path": "src/plugin/config/schema.ts",
    "content": "/**\n * Configuration schema for opencode-antigravity-auth plugin.\n * \n * Config file locations (in priority order, highest wins):\n * - Project: .opencode/antigravity.json\n * - User: ~/.config/opencode/antigravity.json (Linux/Mac)\n *         %APPDATA%\\opencode\\antigravity.json (Windows)\n * \n * Environment variables always override config file values.\n */\n\nimport { z } from \"zod\";\n\n/**\n * Account selection strategy for distributing requests across accounts.\n * \n * - `sticky`: Use same account until rate-limited. Preserves prompt cache.\n * - `round-robin`: Rotate to next account on every request. Maximum throughput.\n * - `hybrid` (default): Deterministic selection based on health score + token bucket + LRU freshness.\n */\nexport const AccountSelectionStrategySchema = z.enum(['sticky', 'round-robin', 'hybrid']);\nexport type AccountSelectionStrategy = z.infer<typeof AccountSelectionStrategySchema>;\n\n/**\n * Toast notification scope for controlling which sessions show toasts.\n * \n * - `root_only` (default): Only show toasts for root sessions (no parentID).\n *   Subagents and background tasks won't show toast notifications.\n * - `all`: Show toasts for all sessions including subagents and background tasks.\n */\nexport const ToastScopeSchema = z.enum(['root_only', 'all']);\nexport type ToastScope = z.infer<typeof ToastScopeSchema>;\n\n/**\n * Scheduling mode for rate limit behavior.\n * \n * - `cache_first`: Wait for same account to recover (preserves prompt cache). Default.\n * - `balance`: Switch account immediately on rate limit. Maximum availability.\n * - `performance_first`: Round-robin distribution for maximum throughput.\n */\nexport const SchedulingModeSchema = z.enum(['cache_first', 'balance', 'performance_first']);\nexport type SchedulingMode = z.infer<typeof SchedulingModeSchema>;\n\n/**\n * Signature cache configuration for persisting thinking block signatures to disk.\n */\nexport const SignatureCacheConfigSchema = z.object({\n  /** Enable disk caching of signatures (default: true) */\n  enabled: z.boolean().default(true),\n  \n  /** In-memory TTL in seconds (default: 3600 = 1 hour) */\n  memory_ttl_seconds: z.number().min(60).max(86400).default(3600),\n  \n  /** Disk TTL in seconds (default: 172800 = 48 hours) */\n  disk_ttl_seconds: z.number().min(3600).max(604800).default(172800),\n  \n  /** Background write interval in seconds (default: 60) */\n  write_interval_seconds: z.number().min(10).max(600).default(60),\n});\n\n/**\n * Main configuration schema for the Antigravity OAuth plugin.\n */\nexport const AntigravityConfigSchema = z.object({\n  /** JSON Schema reference for IDE support */\n  $schema: z.string().optional(),\n  \n  // =========================================================================\n  // General Settings\n  // =========================================================================\n  \n  /** \n   * Suppress most toast notifications (rate limit, account switching, etc.)\n   * Recovery toasts are always shown regardless of this setting.\n   * Env override: OPENCODE_ANTIGRAVITY_QUIET=1\n   * @default false\n   */\n  quiet_mode: z.boolean().default(false),\n  \n  /**\n   * Control which sessions show toast notifications.\n   * \n   * - `root_only` (default): Only root sessions show toasts.\n   *   Subagents and background tasks will be silent (less spam).\n   * - `all`: All sessions show toasts including subagents and background tasks.\n   * \n   * Debug logging captures all toasts regardless of this setting.\n   * Env override: OPENCODE_ANTIGRAVITY_TOAST_SCOPE=all\n   * @default \"root_only\"\n   */\n  toast_scope: ToastScopeSchema.default('root_only'),\n  \n  /**\n   * Enable debug logging to file.\n   * Env override: OPENCODE_ANTIGRAVITY_DEBUG=1\n   * @default false\n   */\n  debug: z.boolean().default(false),\n\n  /**\n   * Show debug logs in the TUI log panel.\n   * Works independently from `debug` file logging.\n   * Env override: OPENCODE_ANTIGRAVITY_DEBUG_TUI=1\n   * @default false\n   */\n  debug_tui: z.boolean().default(false),\n  \n  /**\n   * Custom directory for debug logs.\n   * Env override: OPENCODE_ANTIGRAVITY_LOG_DIR=/path/to/logs\n   * @default OS-specific config dir + \"/antigravity-logs\"\n   */\n  log_dir: z.string().optional(),\n  \n  // =========================================================================\n  // Thinking Blocks\n  // =========================================================================\n  \n  /**\n   * Preserve thinking blocks for Claude models using signature caching.\n   * \n   * When false (default): Thinking blocks are stripped for reliability.\n   * When true: Full context preserved, but may encounter signature errors.\n   * \n   * Env override: OPENCODE_ANTIGRAVITY_KEEP_THINKING=1\n   * @default false\n   */\n  keep_thinking: z.boolean().default(false),\n  \n  // =========================================================================\n  // Session Recovery\n  // =========================================================================\n  \n  /**\n   * Enable automatic session recovery from tool_result_missing errors.\n   * When enabled, shows a toast notification when recoverable errors occur.\n   * \n   * @default true\n   */\n  session_recovery: z.boolean().default(true),\n  \n  /**\n   * Automatically send a \"continue\" prompt after successful recovery.\n   * Only applies when session_recovery is enabled.\n   * \n   * When false: Only shows toast notification, user must manually continue.\n   * When true: Automatically sends \"continue\" to resume the session.\n   * \n   * @default false\n   */\n  auto_resume: z.boolean().default(false),\n  \n  /**\n   * Custom text to send when auto-resuming after recovery.\n   * Only used when auto_resume is enabled.\n   * \n   * @default \"continue\"\n   */\n  resume_text: z.string().default(\"continue\"),\n  \n  // =========================================================================\n  // Signature Caching\n  // =========================================================================\n  \n  /**\n   * Signature cache configuration for persisting thinking block signatures.\n   * Only used when keep_thinking is enabled.\n   */\n  signature_cache: SignatureCacheConfigSchema.optional(),\n  \n  // =========================================================================\n  // Empty Response Retry (ported from LLM-API-Key-Proxy)\n  // =========================================================================\n  \n  /**\n   * Maximum retry attempts when Antigravity returns an empty response.\n   * Empty responses occur when no candidates/choices are returned.\n   * \n   * @default 4\n   */\n  empty_response_max_attempts: z.number().min(1).max(10).default(4),\n  \n  /**\n   * Delay in milliseconds between empty response retries.\n   * \n   * @default 2000\n   */\n  empty_response_retry_delay_ms: z.number().min(500).max(10000).default(2000),\n  \n  // =========================================================================\n  // Tool ID Recovery (ported from LLM-API-Key-Proxy)\n  // =========================================================================\n  \n  /**\n   * Enable tool ID orphan recovery.\n   * When tool responses have mismatched IDs (due to context compaction),\n   * attempt to match them by function name or create placeholders.\n   * \n   * @default true\n   */\n  tool_id_recovery: z.boolean().default(true),\n  \n  // =========================================================================\n  // Tool Hallucination Prevention (ported from LLM-API-Key-Proxy)\n  // =========================================================================\n  \n  /**\n   * Enable tool hallucination prevention for Claude models.\n   * When enabled, injects:\n   * - Parameter signatures into tool descriptions\n   * - System instruction with strict tool usage rules\n   * \n   * This helps prevent Claude from using parameter names from its training\n   * data instead of the actual schema.\n   * \n   * @default true\n   */\n  claude_tool_hardening: z.boolean().default(true),\n\n  /**\n   * Enable Claude prompt auto-caching by adding top-level cache_control when absent.\n   *\n   * @default false\n   */\n  claude_prompt_auto_caching: z.boolean().default(false),\n  \n  // =========================================================================\n  // Proactive Token Refresh (ported from LLM-API-Key-Proxy)\n  // =========================================================================\n  \n  /**\n   * Enable proactive background token refresh.\n   * When enabled, tokens are refreshed in the background before they expire,\n   * ensuring requests never block on token refresh.\n   * \n   * @default true\n   */\n  proactive_token_refresh: z.boolean().default(true),\n  \n  /**\n   * Seconds before token expiry to trigger proactive refresh.\n   * Default is 30 minutes (1800 seconds).\n   * \n   * @default 1800\n   */\n  proactive_refresh_buffer_seconds: z.number().min(60).max(7200).default(1800),\n  \n  /**\n   * Interval between proactive refresh checks in seconds.\n   * Default is 5 minutes (300 seconds).\n   * \n   * @default 300\n   */\n  proactive_refresh_check_interval_seconds: z.number().min(30).max(1800).default(300),\n  \n  // =========================================================================\n  // Rate Limiting\n  // =========================================================================\n  \n  /**\n   * Maximum time in seconds to wait when all accounts are rate-limited.\n   * If the minimum wait time across all accounts exceeds this threshold,\n   * the plugin fails fast with an error instead of hanging.\n   * \n   * Set to 0 to disable (wait indefinitely).\n   * \n   * @default 300 (5 minutes)\n   */\n  max_rate_limit_wait_seconds: z.number().min(0).max(3600).default(300),\n  \n  /**\n   * @deprecated Kept only for backward compatibility.\n   * This flag is ignored at runtime.\n   * Gemini requests always fall back between Antigravity and Gemini CLI quotas.\n   *\n   * @default false\n   */\n  quota_fallback: z.boolean().default(false),\n\n  /**\n   * Prefer gemini-cli routing before Antigravity for Gemini models.\n   * \n   * When false (default): Antigravity is tried first, then gemini-cli.\n   * When true: gemini-cli is tried first, then Antigravity.\n   * \n   * @default false\n   */\n  cli_first: z.boolean().default(false),\n  \n  /**\n   * Strategy for selecting accounts when making requests.\n   * Env override: OPENCODE_ANTIGRAVITY_ACCOUNT_SELECTION_STRATEGY\n   * @default \"hybrid\"\n   */\n  account_selection_strategy: AccountSelectionStrategySchema.default('hybrid'),\n  \n  /**\n   * Enable PID-based account offset for multi-session distribution.\n   * \n   * When enabled, different sessions (PIDs) will prefer different starting\n   * accounts, which helps distribute load when running multiple parallel agents.\n   * \n   * When disabled (default), accounts start from the same index, which preserves\n   * Anthropic's prompt cache across restarts (recommended for single-session use).\n   * \n   * Env override: OPENCODE_ANTIGRAVITY_PID_OFFSET_ENABLED=1\n   * @default false\n   */\n  pid_offset_enabled: z.boolean().default(false),\n   \n   /**\n      * Switch to another account immediately on first rate limit (after 1s delay).\n      * When disabled, retries same account first, then switches on second rate limit.\n      * \n      * @default true\n      */\n    switch_on_first_rate_limit: z.boolean().default(true),\n    \n    /**\n     * Scheduling mode for rate limit behavior.\n     * \n     * - `cache_first`: Wait for same account to recover (preserves prompt cache). Default.\n     * - `balance`: Switch account immediately on rate limit. Maximum availability.\n     * - `performance_first`: Round-robin distribution for maximum throughput.\n     * \n     * Env override: OPENCODE_ANTIGRAVITY_SCHEDULING_MODE\n     * @default \"cache_first\"\n     */\n    scheduling_mode: SchedulingModeSchema.default('cache_first'),\n    \n    /**\n     * Maximum seconds to wait for same account in cache_first mode.\n     * If the account's rate limit reset time exceeds this, switch accounts.\n     * \n     * @default 60\n     */\n    max_cache_first_wait_seconds: z.number().min(5).max(300).default(60),\n    \n    /**\n     * TTL in seconds for failure count expiration.\n     * After this period of no failures, consecutiveFailures resets to 0.\n     * This prevents old failures from permanently penalizing an account.\n     * \n     * @default 3600 (1 hour)\n     */\n    failure_ttl_seconds: z.number().min(60).max(7200).default(3600),\n   \n   /**\n    * Default retry delay in seconds when API doesn't return a retry-after header.\n    * Lower values allow faster retries but may trigger more 429 errors.\n    * \n    * @default 60\n    */\n   default_retry_after_seconds: z.number().min(1).max(300).default(60),\n   \n   /**\n    * Maximum backoff delay in seconds for exponential retry.\n    * This caps how long the exponential backoff can grow.\n    * \n    * @default 60\n    */\n   max_backoff_seconds: z.number().min(5).max(300).default(60),\n   \n   /**\n    * Maximum random delay in milliseconds before each API request.\n    * Adds timing jitter to break predictable request cadence patterns.\n    * Set to 0 to disable request jitter.\n    * \n    * @default 0\n    */\n   request_jitter_max_ms: z.number().min(0).max(5000).default(0),\n   \n   /**\n    * Soft quota threshold percentage (1-100).\n    * When an account's quota usage reaches this percentage, skip it during\n    * account selection (same as if it were rate-limited).\n    * \n    * Example: 90 means skip account when 90% of quota is used (10% remaining).\n    * Set to 100 to disable soft quota protection.\n    * \n    * @default 90\n    */\n   soft_quota_threshold_percent: z.number().min(1).max(100).default(90),\n   \n   /**\n    * How often to refresh quota data in the background (in minutes).\n    * Quota is refreshed opportunistically after successful API requests.\n    * Set to 0 to disable automatic refresh (manual only via Check quotas).\n    * \n    * @default 15\n    */\n   quota_refresh_interval_minutes: z.number().min(0).max(60).default(15),\n   \n   /**\n    * How long quota cache is considered fresh for threshold checks (in minutes).\n    * After this time, cache is stale and account is allowed (fail-open).\n    * \n    * \"auto\" = derive from refresh interval: max(2 * refresh_interval, 10)\n    * \n    * @default \"auto\"\n    */\n   soft_quota_cache_ttl_minutes: z.union([\n     z.literal(\"auto\"),\n     z.number().min(1).max(120)\n   ]).default(\"auto\"),\n   \n   // =========================================================================\n   // Health Score (used by hybrid strategy)\n   // =========================================================================\n   \n   health_score: z.object({\n     initial: z.number().min(0).max(100).default(70),\n     success_reward: z.number().min(0).max(10).default(1),\n     rate_limit_penalty: z.number().min(-50).max(0).default(-10),\n     failure_penalty: z.number().min(-100).max(0).default(-20),\n     recovery_rate_per_hour: z.number().min(0).max(20).default(2),\n     min_usable: z.number().min(0).max(100).default(50),\n     max_score: z.number().min(50).max(100).default(100),\n   }).optional(),\n   \n   // =========================================================================\n   // Token Bucket (for hybrid strategy)\n   // =========================================================================\n   \n   token_bucket: z.object({\n     max_tokens: z.number().min(1).max(1000).default(50),\n     regeneration_rate_per_minute: z.number().min(0.1).max(60).default(6),\n     initial_tokens: z.number().min(1).max(1000).default(50),\n   }).optional(),\n   \n   // =========================================================================\n   // Auto-Update\n  // =========================================================================\n  \n  /**\n   * Enable automatic plugin updates.\n   * @default true\n   */\n  auto_update: z.boolean().default(true),\n\n});\n\nexport type AntigravityConfig = z.infer<typeof AntigravityConfigSchema>;\nexport type SignatureCacheConfig = z.infer<typeof SignatureCacheConfigSchema>;\n\n/**\n * Default configuration values.\n */\nexport const DEFAULT_CONFIG: AntigravityConfig = {\n  quiet_mode: false,\n  toast_scope: 'root_only',\n  debug: false,\n  debug_tui: false,\n  keep_thinking: false,\n  session_recovery: true,\n  auto_resume: true,\n  resume_text: \"continue\",\n  empty_response_max_attempts: 4,\n  empty_response_retry_delay_ms: 2000,\n  tool_id_recovery: true,\n  claude_tool_hardening: true,\n  claude_prompt_auto_caching: false,\n  proactive_token_refresh: true,\n  proactive_refresh_buffer_seconds: 1800,\n  proactive_refresh_check_interval_seconds: 300,\n  max_rate_limit_wait_seconds: 300,\n  quota_fallback: false,\n  cli_first: false,\n  account_selection_strategy: 'hybrid',\n  pid_offset_enabled: false,\n  switch_on_first_rate_limit: true,\n  scheduling_mode: 'cache_first',\n  max_cache_first_wait_seconds: 60,\n  failure_ttl_seconds: 3600,\n  default_retry_after_seconds: 60,\n  max_backoff_seconds: 60,\n  request_jitter_max_ms: 0,\n  soft_quota_threshold_percent: 90,\n  quota_refresh_interval_minutes: 15,\n  soft_quota_cache_ttl_minutes: \"auto\",\n  auto_update: true,\n  signature_cache: {\n    enabled: true,\n    memory_ttl_seconds: 3600,\n    disk_ttl_seconds: 172800,\n    write_interval_seconds: 60,\n  },\n  health_score: {\n    initial: 70,\n    success_reward: 1,\n    rate_limit_penalty: -10,\n    failure_penalty: -20,\n    recovery_rate_per_hour: 2,\n    min_usable: 50,\n    max_score: 100,\n  },\n  token_bucket: {\n    max_tokens: 50,\n    regeneration_rate_per_minute: 6,\n    initial_tokens: 50,\n  },\n};\n"
  },
  {
    "path": "src/plugin/config/updater.test.ts",
    "content": "import { describe, test, expect, beforeEach, afterEach } from \"vitest\";\nimport * as fs from \"node:fs\";\nimport * as path from \"node:path\";\nimport * as os from \"node:os\";\nimport { updateOpencodeConfig } from \"./updater\";\nimport { OPENCODE_MODEL_DEFINITIONS } from \"./models\";\n\ndescribe(\"updateOpencodeConfig\", () => {\n  let tempDir: string;\n  let configPath: string;\n  let originalXdgConfigHome: string | undefined;\n\n  beforeEach(() => {\n    originalXdgConfigHome = process.env.XDG_CONFIG_HOME;\n    // Create a temporary directory for each test\n    tempDir = fs.mkdtempSync(path.join(os.tmpdir(), \"opencode-test-\"));\n    configPath = path.join(tempDir, \"opencode.json\");\n  });\n\n  afterEach(() => {\n    if (originalXdgConfigHome === undefined) {\n      delete process.env.XDG_CONFIG_HOME;\n    } else {\n      process.env.XDG_CONFIG_HOME = originalXdgConfigHome;\n    }\n\n    // Clean up temp directory\n    if (fs.existsSync(tempDir)) {\n      fs.rmSync(tempDir, { recursive: true, force: true });\n    }\n  });\n\n  test(\"creates new config with default structure when file does not exist\", async () => {\n    const result = await updateOpencodeConfig({ configPath });\n\n    expect(result.success).toBe(true);\n    expect(result.configPath).toBe(configPath);\n    expect(fs.existsSync(configPath)).toBe(true);\n\n    // Verify written config has correct structure\n    const writtenConfig = JSON.parse(fs.readFileSync(configPath, \"utf-8\"));\n    expect(writtenConfig.$schema).toBe(\"https://opencode.ai/config.json\");\n    expect(writtenConfig.plugin).toContain(\"opencode-antigravity-auth@latest\");\n    expect(writtenConfig.provider?.google?.models).toBeDefined();\n  });\n\n  test(\"replaces existing google models with plugin models\", async () => {\n    const existingConfig = {\n      $schema: \"https://opencode.ai/config.json\",\n      plugin: [\"opencode-antigravity-auth@latest\"],\n      provider: {\n        google: {\n          models: {\n            \"old-model\": { name: \"Old Model\" },\n          },\n        },\n      },\n    };\n    fs.writeFileSync(configPath, JSON.stringify(existingConfig));\n\n    const result = await updateOpencodeConfig({ configPath });\n\n    expect(result.success).toBe(true);\n\n    const writtenConfig = JSON.parse(fs.readFileSync(configPath, \"utf-8\"));\n    // Old model should be replaced\n    expect(writtenConfig.provider.google.models[\"old-model\"]).toBeUndefined();\n    // New models should be present\n    expect(writtenConfig.provider.google.models[\"antigravity-gemini-3-pro\"]).toBeDefined();\n    expect(writtenConfig.provider.google.models[\"antigravity-claude-sonnet-4-6\"]).toBeDefined();\n  });\n\n  test(\"preserves non-google provider sections\", async () => {\n    const existingConfig = {\n      $schema: \"https://opencode.ai/config.json\",\n      plugin: [\"opencode-antigravity-auth@latest\"],\n      provider: {\n        google: {\n          models: { \"old-model\": {} },\n        },\n        anthropic: {\n          apiKey: \"secret-key\",\n          models: { \"claude-3\": {} },\n        },\n        openai: {\n          models: { \"gpt-4\": {} },\n        },\n      },\n    };\n    fs.writeFileSync(configPath, JSON.stringify(existingConfig));\n\n    const result = await updateOpencodeConfig({ configPath });\n\n    expect(result.success).toBe(true);\n\n    const writtenConfig = JSON.parse(fs.readFileSync(configPath, \"utf-8\"));\n    // Non-google providers should be preserved\n    expect(writtenConfig.provider.anthropic).toEqual(existingConfig.provider.anthropic);\n    expect(writtenConfig.provider.openai).toEqual(existingConfig.provider.openai);\n  });\n\n  test(\"preserves $schema and other top-level config keys\", async () => {\n    const existingConfig = {\n      $schema: \"https://opencode.ai/config.json\",\n      plugin: [\"opencode-antigravity-auth@latest\", \"other-plugin\"],\n      theme: \"dark\",\n      customSetting: { nested: true },\n      provider: {\n        google: { models: {} },\n      },\n    };\n    fs.writeFileSync(configPath, JSON.stringify(existingConfig));\n\n    const result = await updateOpencodeConfig({ configPath });\n\n    expect(result.success).toBe(true);\n\n    const writtenConfig = JSON.parse(fs.readFileSync(configPath, \"utf-8\"));\n    expect(writtenConfig.$schema).toBe(\"https://opencode.ai/config.json\");\n    expect(writtenConfig.plugin).toContain(\"other-plugin\");\n    expect(writtenConfig.theme).toBe(\"dark\");\n    expect(writtenConfig.customSetting).toEqual({ nested: true });\n  });\n\n  test(\"adds plugin to existing plugin array if not present\", async () => {\n    const existingConfig = {\n      plugin: [\"other-plugin\"],\n      provider: {},\n    };\n    fs.writeFileSync(configPath, JSON.stringify(existingConfig));\n\n    const result = await updateOpencodeConfig({ configPath });\n\n    expect(result.success).toBe(true);\n\n    const writtenConfig = JSON.parse(fs.readFileSync(configPath, \"utf-8\"));\n    expect(writtenConfig.plugin).toContain(\"opencode-antigravity-auth@latest\");\n    expect(writtenConfig.plugin).toContain(\"other-plugin\");\n  });\n\n  test(\"does not duplicate plugin if already present\", async () => {\n    const existingConfig = {\n      plugin: [\"opencode-antigravity-auth@latest\", \"other-plugin\"],\n      provider: {},\n    };\n    fs.writeFileSync(configPath, JSON.stringify(existingConfig));\n\n    const result = await updateOpencodeConfig({ configPath });\n\n    expect(result.success).toBe(true);\n\n    const writtenConfig = JSON.parse(fs.readFileSync(configPath, \"utf-8\"));\n    const pluginCount = writtenConfig.plugin.filter(\n      (p: string) => p.includes(\"opencode-antigravity-auth\")\n    ).length;\n    expect(pluginCount).toBe(1);\n  });\n\n  test(\"does not duplicate plugin if different version present\", async () => {\n    const existingConfig = {\n      plugin: [\"opencode-antigravity-auth@beta\", \"other-plugin\"],\n      provider: {},\n    };\n    fs.writeFileSync(configPath, JSON.stringify(existingConfig));\n\n    const result = await updateOpencodeConfig({ configPath });\n\n    expect(result.success).toBe(true);\n\n    const writtenConfig = JSON.parse(fs.readFileSync(configPath, \"utf-8\"));\n    const pluginCount = writtenConfig.plugin.filter(\n      (p: string) => p.includes(\"opencode-antigravity-auth\")\n    ).length;\n    // Should not add another version if one exists\n    expect(pluginCount).toBe(1);\n    // Should preserve the existing version\n    expect(writtenConfig.plugin).toContain(\"opencode-antigravity-auth@beta\");\n  });\n\n  test(\"writes config with proper JSON formatting (2-space indent)\", async () => {\n    const result = await updateOpencodeConfig({ configPath });\n\n    expect(result.success).toBe(true);\n\n    const writtenContent = fs.readFileSync(configPath, \"utf-8\");\n    // Should have newlines and 2-space indentation\n    expect(writtenContent).toContain(\"\\n\");\n    expect(writtenContent).toMatch(/^\\{\\n {2}/);\n  });\n\n  test(\"returns error result on invalid JSON in existing config\", async () => {\n    fs.writeFileSync(configPath, \"{ invalid json }\");\n\n    const result = await updateOpencodeConfig({ configPath });\n\n    expect(result.success).toBe(false);\n    expect(result.error).toBeDefined();\n  });\n\n  test(\"includes all model definitions from OPENCODE_MODEL_DEFINITIONS\", async () => {\n    const result = await updateOpencodeConfig({ configPath });\n\n    expect(result.success).toBe(true);\n\n    const writtenConfig = JSON.parse(fs.readFileSync(configPath, \"utf-8\"));\n    const models = writtenConfig.provider.google.models;\n\n    // Verify all models from OPENCODE_MODEL_DEFINITIONS are included\n    for (const modelKey of Object.keys(OPENCODE_MODEL_DEFINITIONS)) {\n      expect(models[modelKey]).toBeDefined();\n    }\n  });\n\n  test(\"parses existing jsonc config files with comments and trailing commas\", async () => {\n    const jsoncPath = path.join(tempDir, \"opencode.jsonc\");\n    const existingJsoncConfig = `{\n  // Keep existing plugin\n  \"plugin\": [\n    \"other-plugin\",\n  ],\n  \"provider\": {\n    \"google\": {\n      \"region\": \"us-central1\",\n    },\n  },\n}`;\n    fs.writeFileSync(jsoncPath, existingJsoncConfig);\n\n    const result = await updateOpencodeConfig({ configPath: jsoncPath });\n\n    expect(result.success).toBe(true);\n    expect(result.configPath).toBe(jsoncPath);\n\n    const writtenConfig = JSON.parse(fs.readFileSync(jsoncPath, \"utf-8\"));\n    expect(writtenConfig.plugin).toContain(\"other-plugin\");\n    expect(writtenConfig.plugin).toContain(\"opencode-antigravity-auth@latest\");\n    expect(writtenConfig.provider.google.region).toBe(\"us-central1\");\n    expect(writtenConfig.provider.google.models[\"antigravity-gemini-3-pro\"]).toBeDefined();\n  });\n\n  test(\"prefers existing opencode.jsonc when using default config path\", async () => {\n    const opencodeDir = path.join(tempDir, \"opencode\");\n    const jsonPath = path.join(opencodeDir, \"opencode.json\");\n    const jsoncPath = path.join(opencodeDir, \"opencode.jsonc\");\n\n    fs.mkdirSync(opencodeDir, { recursive: true });\n    fs.writeFileSync(jsoncPath, JSON.stringify({ plugin: [\"other-plugin\"], provider: {} }, null, 2));\n    process.env.XDG_CONFIG_HOME = tempDir;\n\n    const result = await updateOpencodeConfig();\n\n    expect(result.success).toBe(true);\n    expect(result.configPath).toBe(jsoncPath);\n    expect(fs.existsSync(jsonPath)).toBe(false);\n    expect(fs.existsSync(jsoncPath)).toBe(true);\n  });\n\n  test(\"creates parent directory if it does not exist\", async () => {\n    const nestedPath = path.join(tempDir, \"nested\", \"dir\", \"opencode.json\");\n\n    const result = await updateOpencodeConfig({ configPath: nestedPath });\n\n    expect(result.success).toBe(true);\n    expect(fs.existsSync(nestedPath)).toBe(true);\n  });\n\n  test(\"adds $schema if missing from existing config\", async () => {\n    const existingConfig = {\n      plugin: [\"opencode-antigravity-auth@latest\"],\n      provider: { google: {} },\n    };\n    fs.writeFileSync(configPath, JSON.stringify(existingConfig));\n\n    const result = await updateOpencodeConfig({ configPath });\n\n    expect(result.success).toBe(true);\n\n    const writtenConfig = JSON.parse(fs.readFileSync(configPath, \"utf-8\"));\n    expect(writtenConfig.$schema).toBe(\"https://opencode.ai/config.json\");\n  });\n\n  test(\"preserves other google provider settings besides models\", async () => {\n    const existingConfig = {\n      plugin: [\"opencode-antigravity-auth@latest\"],\n      provider: {\n        google: {\n          apiKey: \"test-key\",\n          models: { \"old-model\": {} },\n          customSetting: true,\n        },\n      },\n    };\n    fs.writeFileSync(configPath, JSON.stringify(existingConfig));\n\n    const result = await updateOpencodeConfig({ configPath });\n\n    expect(result.success).toBe(true);\n\n    const writtenConfig = JSON.parse(fs.readFileSync(configPath, \"utf-8\"));\n    // Other google settings should be preserved\n    expect(writtenConfig.provider.google.apiKey).toBe(\"test-key\");\n    expect(writtenConfig.provider.google.customSetting).toBe(true);\n    // But models should be replaced\n    expect(writtenConfig.provider.google.models[\"old-model\"]).toBeUndefined();\n  });\n});\n"
  },
  {
    "path": "src/plugin/config/updater.ts",
    "content": "/**\n * OpenCode configuration file updater.\n *\n * Updates ~/.config/opencode/opencode.json(c) with plugin models.\n */\n\nimport { existsSync, readFileSync, writeFileSync, mkdirSync } from \"node:fs\";\nimport { join, dirname } from \"node:path\";\nimport { homedir } from \"node:os\";\nimport { OPENCODE_MODEL_DEFINITIONS } from \"./models\";\n\n// =============================================================================\n// Types\n// =============================================================================\n\nexport interface UpdateConfigResult {\n  success: boolean;\n  configPath: string;\n  error?: string;\n}\n\nexport interface OpencodeConfig {\n  $schema?: string;\n  plugin?: string[];\n  provider?: {\n    google?: {\n      models?: Record<string, unknown>;\n      [key: string]: unknown;\n    };\n    [key: string]: unknown;\n  };\n  [key: string]: unknown;\n}\n\nexport interface UpdateConfigOptions {\n  /** Override the config file path (for testing) */\n  configPath?: string;\n}\n\n// =============================================================================\n// Constants\n// =============================================================================\n\nconst PLUGIN_NAME = \"opencode-antigravity-auth@latest\";\nconst SCHEMA_URL = \"https://opencode.ai/config.json\";\nconst OPENCODE_JSON_FILENAME = \"opencode.json\";\nconst OPENCODE_JSONC_FILENAME = \"opencode.jsonc\";\n\nfunction stripJsonCommentsAndTrailingCommas(json: string): string {\n  return json\n    .replace(\n      /\\\\\"|\"(?:\\\\\"|[^\"])*\"|(\\/\\/.*|\\/\\*[\\s\\S]*?\\*\\/)/g,\n      (match: string, group: string | undefined) => (group ? \"\" : match)\n    )\n    .replace(/,(\\s*[}\\]])/g, \"$1\");\n}\n\n/**\n * Get the opencode config directory path.\n */\nexport function getOpencodeConfigDir(): string {\n  const xdgConfig = process.env.XDG_CONFIG_HOME || join(homedir(), \".config\");\n  return join(xdgConfig, \"opencode\");\n}\n\n/**\n * Get the opencode config file path.\n *\n * Prefers opencode.jsonc when present so we update the active config file\n * instead of creating a new opencode.json.\n */\nexport function getOpencodeConfigPath(): string {\n  const configDir = getOpencodeConfigDir();\n  const jsoncPath = join(configDir, OPENCODE_JSONC_FILENAME);\n  const jsonPath = join(configDir, OPENCODE_JSON_FILENAME);\n\n  if (existsSync(jsoncPath)) {\n    return jsoncPath;\n  }\n  if (existsSync(jsonPath)) {\n    return jsonPath;\n  }\n\n  return jsonPath;\n}\n\n// =============================================================================\n// Main Function\n// =============================================================================\n\n/**\n * Updates the opencode configuration file with plugin models.\n *\n * This function:\n * 1. Reads existing opencode.json/opencode.jsonc (or creates default structure)\n * 2. Replaces `provider.google.models` with plugin models\n * 3. Writes back to disk with proper formatting\n *\n * Preserves:\n * - $schema and other top-level config keys\n * - Non-google provider sections\n * - Other settings within google provider (except models)\n *\n * @param options - Optional configuration (e.g., custom configPath for testing)\n * @returns UpdateConfigResult with success status and path\n */\nexport async function updateOpencodeConfig(\n  options: UpdateConfigOptions = {}\n): Promise<UpdateConfigResult> {\n  const configPath = options.configPath ?? getOpencodeConfigPath();\n\n  try {\n    let config: OpencodeConfig;\n\n    // Read existing config or create default\n    if (existsSync(configPath)) {\n      const content = readFileSync(configPath, \"utf-8\");\n      config = JSON.parse(stripJsonCommentsAndTrailingCommas(content)) as OpencodeConfig;\n    } else {\n      // Create default config structure\n      config = {\n        $schema: SCHEMA_URL,\n        plugin: [],\n        provider: {},\n      };\n    }\n\n    // Ensure $schema is set\n    if (!config.$schema) {\n      config.$schema = SCHEMA_URL;\n    }\n\n    // Ensure plugin array exists and contains our plugin\n    if (!Array.isArray(config.plugin)) {\n      config.plugin = [];\n    }\n\n    // Check if plugin is already in the list (any version)\n    const hasPlugin = config.plugin.some((p) =>\n      p.includes(\"opencode-antigravity-auth\")\n    );\n    if (!hasPlugin) {\n      config.plugin.push(PLUGIN_NAME);\n    }\n\n    // Ensure provider.google structure exists\n    if (!config.provider) {\n      config.provider = {};\n    }\n    if (!config.provider.google) {\n      config.provider.google = {};\n    }\n\n    // Replace google models with plugin models\n    config.provider.google.models = { ...OPENCODE_MODEL_DEFINITIONS };\n\n    // Ensure config directory exists\n    const configDir = dirname(configPath);\n    if (!existsSync(configDir)) {\n      mkdirSync(configDir, { recursive: true });\n    }\n\n    // Write config with proper formatting (2-space indent)\n    writeFileSync(configPath, JSON.stringify(config, null, 2), \"utf-8\");\n\n    return {\n      success: true,\n      configPath,\n    };\n  } catch (error) {\n    return {\n      success: false,\n      configPath,\n      error: error instanceof Error ? error.message : String(error),\n    };\n  }\n}\n"
  },
  {
    "path": "src/plugin/core/streaming/index.ts",
    "content": "export * from './types';\nexport * from './transformer';\n"
  },
  {
    "path": "src/plugin/core/streaming/transformer.ts",
    "content": "import type {\n  SignatureStore,\n  StreamingCallbacks,\n  StreamingOptions,\n  ThoughtBuffer,\n} from './types';\nimport { processImageData } from '../../image-saver';\n\n/**\n * Simple string hash for thinking deduplication.\n * Uses DJB2-like algorithm.\n */\nfunction hashString(str: string): string {\n  let hash = 5381;\n  for (let i = 0; i < str.length; i++) {\n    hash = ((hash << 5) + hash) + str.charCodeAt(i); /* hash * 33 + c */\n  }\n  return (hash >>> 0).toString(16);\n}\n\nexport function createThoughtBuffer(): ThoughtBuffer {\n  const buffer = new Map<number, string>();\n  return {\n    get: (index: number) => buffer.get(index),\n    set: (index: number, text: string) => buffer.set(index, text),\n    clear: () => buffer.clear(),\n  };\n}\n\nexport function transformStreamingPayload(\n  payload: string,\n  transformThinkingParts?: (response: unknown) => unknown,\n): string {\n  return payload\n    .split('\\n')\n    .map((line) => {\n      if (!line.startsWith('data:')) {\n        return line;\n      }\n      const json = line.slice(5).trim();\n      if (!json) {\n        return line;\n      }\n      try {\n        const parsed = JSON.parse(json) as { response?: unknown };\n        if (parsed.response !== undefined) {\n          const transformed = transformThinkingParts\n            ? transformThinkingParts(parsed.response)\n            : parsed.response;\n          return `data: ${JSON.stringify(transformed)}`;\n        }\n      } catch (_) {}\n      return line;\n    })\n    .join('\\n');\n}\n\nexport function deduplicateThinkingText(\n  response: unknown,\n  sentBuffer: ThoughtBuffer,\n  displayedThinkingHashes?: Set<string>,\n): unknown {\n  if (!response || typeof response !== 'object') return response;\n\n  const resp = response as Record<string, unknown>;\n\n  if (Array.isArray(resp.candidates)) {\n    const newCandidates = resp.candidates.map((candidate: unknown, index: number) => {\n      const cand = candidate as Record<string, unknown> | null;\n      if (!cand?.content) return candidate;\n\n      const content = cand.content as Record<string, unknown>;\n      if (!Array.isArray(content.parts)) return candidate;\n\n      const newParts = content.parts.map((part: unknown) => {\n        const p = part as Record<string, unknown>;\n        \n        // Handle image data - save to disk and return file path\n        if (p.inlineData) {\n          const inlineData = p.inlineData as Record<string, unknown>;\n          const result = processImageData({\n            mimeType: inlineData.mimeType as string | undefined,\n            data: inlineData.data as string | undefined,\n          });\n          if (result) {\n            return { text: result };\n          }\n        }\n        \n        if (p.thought === true || p.type === 'thinking') {\n          const fullText = (p.text || p.thinking || '') as string;\n          \n          if (displayedThinkingHashes) {\n            const hash = hashString(fullText);\n            if (displayedThinkingHashes.has(hash)) {\n              sentBuffer.set(index, fullText);\n              return null;\n            }\n            displayedThinkingHashes.add(hash);\n          }\n\n          const sentText = sentBuffer.get(index) ?? '';\n\n          if (fullText.startsWith(sentText)) {\n            const delta = fullText.slice(sentText.length);\n            sentBuffer.set(index, fullText);\n\n            if (delta) {\n              return { ...p, text: delta, thinking: delta };\n            }\n            return null;\n          }\n\n          sentBuffer.set(index, fullText);\n          return part;\n        }\n        return part;\n      });\n\n      const filteredParts = newParts.filter((p) => p !== null);\n\n      return {\n        ...cand,\n        content: { ...content, parts: filteredParts },\n      };\n    });\n\n    return { ...resp, candidates: newCandidates };\n  }\n\n  if (Array.isArray(resp.content)) {\n    let thinkingIndex = 0;\n    const newContent = resp.content.map((block: unknown) => {\n      const b = block as Record<string, unknown> | null;\n      if (b?.type === 'thinking') {\n        const fullText = (b.thinking || b.text || '') as string;\n        \n        if (displayedThinkingHashes) {\n          const hash = hashString(fullText);\n          if (displayedThinkingHashes.has(hash)) {\n            sentBuffer.set(thinkingIndex, fullText);\n            thinkingIndex++;\n            return null;\n          }\n          displayedThinkingHashes.add(hash);\n        }\n\n        const sentText = sentBuffer.get(thinkingIndex) ?? '';\n\n        if (fullText.startsWith(sentText)) {\n          const delta = fullText.slice(sentText.length);\n          sentBuffer.set(thinkingIndex, fullText);\n          thinkingIndex++;\n\n          if (delta) {\n            return { ...b, thinking: delta, text: delta };\n          }\n          return null;\n        }\n\n        sentBuffer.set(thinkingIndex, fullText);\n        thinkingIndex++;\n        return block;\n      }\n      return block;\n    });\n\n    const filteredContent = newContent.filter((b) => b !== null);\n    return { ...resp, content: filteredContent };\n  }\n\n  return response;\n}\n\nexport function transformSseLine(\n  line: string,\n  signatureStore: SignatureStore,\n  thoughtBuffer: ThoughtBuffer,\n  sentThinkingBuffer: ThoughtBuffer,\n  callbacks: StreamingCallbacks,\n  options: StreamingOptions,\n  debugState: { injected: boolean },\n): string {\n  if (!line.startsWith('data:')) {\n    return line;\n  }\n  const json = line.slice(5).trim();\n  if (!json) {\n    return line;\n  }\n\n  try {\n    const parsed = JSON.parse(json) as { response?: unknown };\n    if (parsed.response !== undefined) {\n      if (options.cacheSignatures && options.signatureSessionKey) {\n        cacheThinkingSignaturesFromResponse(\n          parsed.response,\n          options.signatureSessionKey,\n          signatureStore,\n          thoughtBuffer,\n          callbacks.onCacheSignature,\n        );\n      }\n\n      let response: unknown = deduplicateThinkingText(\n        parsed.response,\n        sentThinkingBuffer,\n        options.displayedThinkingHashes\n      );\n\n      if (options.debugText && callbacks.onInjectDebug && !debugState.injected) {\n        response = callbacks.onInjectDebug(response, options.debugText);\n        debugState.injected = true;\n      }\n      // Note: onInjectSyntheticThinking removed - keep_thinking now uses debugText path\n\n      const transformed = callbacks.transformThinkingParts\n        ? callbacks.transformThinkingParts(response)\n        : response;\n      return `data: ${JSON.stringify(transformed)}`;\n    }\n  } catch (_) {}\n  return line;\n}\n\nexport function cacheThinkingSignaturesFromResponse(\n  response: unknown,\n  signatureSessionKey: string,\n  signatureStore: SignatureStore,\n  thoughtBuffer: ThoughtBuffer,\n  onCacheSignature?: (sessionKey: string, text: string, signature: string) => void,\n): void {\n  if (!response || typeof response !== 'object') return;\n\n  const resp = response as Record<string, unknown>;\n\n  if (Array.isArray(resp.candidates)) {\n    resp.candidates.forEach((candidate: unknown, index: number) => {\n      const cand = candidate as Record<string, unknown> | null;\n      if (!cand?.content) return;\n      const content = cand.content as Record<string, unknown>;\n      if (!Array.isArray(content.parts)) return;\n\n      content.parts.forEach((part: unknown) => {\n        const p = part as Record<string, unknown>;\n        if (p.thought === true || p.type === 'thinking') {\n          const text = (p.text || p.thinking || '') as string;\n          if (text) {\n            const current = thoughtBuffer.get(index) ?? '';\n            thoughtBuffer.set(index, current + text);\n          }\n        }\n\n        if (p.thoughtSignature) {\n          const fullText = thoughtBuffer.get(index) ?? '';\n          if (fullText) {\n            const signature = p.thoughtSignature as string;\n            onCacheSignature?.(signatureSessionKey, fullText, signature);\n            signatureStore.set(signatureSessionKey, { text: fullText, signature });\n          }\n        }\n      });\n    });\n  }\n\n  if (Array.isArray(resp.content)) {\n    // Use thoughtBuffer to accumulate thinking text across SSE events\n    // Claude streams thinking content and signature in separate events\n    const CLAUDE_BUFFER_KEY = 0; // Use index 0 for Claude's single-stream content\n    resp.content.forEach((block: unknown) => {\n      const b = block as Record<string, unknown> | null;\n      if (b?.type === 'thinking') {\n        const text = (b.thinking || b.text || '') as string;\n        if (text) {\n          const current = thoughtBuffer.get(CLAUDE_BUFFER_KEY) ?? '';\n          thoughtBuffer.set(CLAUDE_BUFFER_KEY, current + text);\n        }\n      }\n      if (b?.signature) {\n        const fullText = thoughtBuffer.get(CLAUDE_BUFFER_KEY) ?? '';\n        if (fullText) {\n          const signature = b.signature as string;\n          onCacheSignature?.(signatureSessionKey, fullText, signature);\n          signatureStore.set(signatureSessionKey, { text: fullText, signature });\n        }\n      }\n    });\n  }\n}\n\nexport function createStreamingTransformer(\n  signatureStore: SignatureStore,\n  callbacks: StreamingCallbacks,\n  options: StreamingOptions = {},\n): TransformStream<Uint8Array, Uint8Array> {\n  const decoder = new TextDecoder();\n  const encoder = new TextEncoder();\n  let buffer = '';\n  const thoughtBuffer = createThoughtBuffer();\n  const sentThinkingBuffer = createThoughtBuffer();\n  const debugState = { injected: false };\n  let hasSeenUsageMetadata = false;\n\n  return new TransformStream({\n    transform(chunk, controller) {\n      buffer += decoder.decode(chunk, { stream: true });\n\n      const lines = buffer.split('\\n');\n      buffer = lines.pop() || '';\n\n      for (const line of lines) {\n        // Quick check for usage metadata presence in the raw line\n        if (line.includes('usageMetadata')) {\n          hasSeenUsageMetadata = true;\n        }\n\n        const transformedLine = transformSseLine(\n          line,\n          signatureStore,\n          thoughtBuffer,\n          sentThinkingBuffer,\n          callbacks,\n          options,\n          debugState,\n        );\n        controller.enqueue(encoder.encode(transformedLine + '\\n'));\n      }\n    },\n    flush(controller) {\n      buffer += decoder.decode();\n\n      if (buffer) {\n        if (buffer.includes('usageMetadata')) {\n          hasSeenUsageMetadata = true;\n        }\n        const transformedLine = transformSseLine(\n          buffer,\n          signatureStore,\n          thoughtBuffer,\n          sentThinkingBuffer,\n          callbacks,\n          options,\n          debugState,\n        );\n        controller.enqueue(encoder.encode(transformedLine));\n      }\n\n      // Inject synthetic usage metadata if missing (fixes \"Context % used: 0%\" issue)\n      if (!hasSeenUsageMetadata) {\n        const syntheticUsage = {\n          response: {\n            usageMetadata: {\n              promptTokenCount: 0,\n              candidatesTokenCount: 0,\n              totalTokenCount: 0,\n            }\n          }\n        };\n        controller.enqueue(encoder.encode(`\\ndata: ${JSON.stringify(syntheticUsage)}\\n\\n`));\n      }\n    },\n  });\n}\n"
  },
  {
    "path": "src/plugin/core/streaming/types.ts",
    "content": "export interface SignedThinking {\n  text: string;\n  signature: string;\n}\n\nexport interface SignatureStore {\n  get(sessionKey: string): SignedThinking | undefined;\n  set(sessionKey: string, value: SignedThinking): void;\n  has(sessionKey: string): boolean;\n  delete(sessionKey: string): void;\n}\n\nexport interface StreamingCallbacks {\n  onCacheSignature?: (sessionKey: string, text: string, signature: string) => void;\n  onInjectDebug?: (response: unknown, debugText: string) => unknown;\n  // Note: onInjectSyntheticThinking removed - keep_thinking now unified with debug via debugText\n  transformThinkingParts?: (parts: unknown) => unknown;\n}\n\nexport interface StreamingOptions {\n  signatureSessionKey?: string;\n  debugText?: string;\n  cacheSignatures?: boolean;\n  displayedThinkingHashes?: Set<string>;\n  // Note: injectSyntheticThinking removed - keep_thinking now unified with debug via debugText\n}\n\nexport interface ThoughtBuffer {\n  get(index: number): string | undefined;\n  set(index: number, text: string): void;\n  clear(): void;\n}\n"
  },
  {
    "path": "src/plugin/cross-model-integration.test.ts",
    "content": "import { describe, it, expect } from \"vitest\";\nimport {\n  sanitizeCrossModelPayload,\n  getModelFamily,\n} from \"./transform/cross-model-sanitizer\";\n\ndescribe(\"Cross-Model Session Integration\", () => {\n  describe(\"Gemini → Claude model switch with tool calls\", () => {\n    it(\"sanitizes Gemini thinking metadata when preparing Claude request\", () => {\n      const geminiSessionHistory = {\n        contents: [\n          {\n            role: \"user\",\n            parts: [\n              {\n                text: \"Check disk space. Think about which filesystems are most utilized.\",\n              },\n            ],\n          },\n          {\n            role: \"model\",\n            parts: [\n              {\n                thought: true,\n                text: \"I need to analyze disk usage...\",\n                thoughtSignature: \"EsgQCsUQAXLI2nybuafAE150LGTo2r78fakesig\",\n              },\n              {\n                functionCall: { name: \"bash\", args: { command: \"df -h\" } },\n                metadata: {\n                  google: {\n                    thoughtSignature:\n                      \"EsgQCsUQAXLI2nybuafAE150LGTo2r78fakesig\",\n                  },\n                },\n              },\n            ],\n          },\n          {\n            role: \"function\",\n            parts: [\n              {\n                functionResponse: {\n                  name: \"bash\",\n                  response: { output: \"Filesystem Size Used Avail Use%...\" },\n                },\n              },\n            ],\n          },\n          {\n            role: \"model\",\n            parts: [{ text: \"The root filesystem is 62% utilized...\" }],\n          },\n        ],\n      };\n\n      const payload = {\n        model: \"claude-opus-4-6-thinking-medium\",\n        ...geminiSessionHistory,\n        contents: [\n          ...geminiSessionHistory.contents,\n          {\n            role: \"user\",\n            parts: [{ text: \"Now check memory usage with free -h\" }],\n          },\n        ],\n      };\n\n      const result = sanitizeCrossModelPayload(payload, {\n        targetModel: \"claude-opus-4-6-thinking-medium\",\n      });\n\n      const sanitized = result.payload as typeof payload;\n      const modelParts = sanitized.contents[1]!.parts;\n\n      expect(\n        (modelParts[0] as Record<string, unknown>).thoughtSignature\n      ).toBeUndefined();\n      expect(\n        (modelParts[1] as Record<string, unknown>).metadata\n      ).toBeUndefined();\n      expect(\n        (modelParts[1] as Record<string, unknown> & { functionCall: { name: string } }).functionCall.name\n      ).toBe(\"bash\");\n\n      expect(result.modified).toBe(true);\n      expect(result.signaturesStripped).toBeGreaterThan(0);\n    });\n\n    it(\"preserves non-signature metadata\", () => {\n      const payload = {\n        contents: [\n          {\n            role: \"model\",\n            parts: [\n              {\n                functionCall: { name: \"read\", args: { path: \"/etc/passwd\" } },\n                metadata: {\n                  google: {\n                    thoughtSignature: \"should-be-stripped\",\n                    groundingMetadata: { searchQueries: [\"test\"] },\n                    searchEntryPoint: { renderedContent: \"test\" },\n                  },\n                  cache_control: { type: \"ephemeral\" },\n                },\n              },\n            ],\n          },\n        ],\n      };\n\n      const result = sanitizeCrossModelPayload(payload, {\n        targetModel: \"claude-sonnet-4\",\n        preserveNonSignatureMetadata: true,\n      });\n\n      const sanitized = result.payload as typeof payload;\n      const partMeta = (sanitized.contents[0]!.parts![0] as Record<string, unknown>)\n        .metadata as Record<string, unknown>;\n      const googleMeta = partMeta.google as Record<string, unknown>;\n\n      expect(googleMeta.thoughtSignature).toBeUndefined();\n      expect(googleMeta.groundingMetadata).toEqual({ searchQueries: [\"test\"] });\n      expect(googleMeta.searchEntryPoint).toEqual({ renderedContent: \"test\" });\n      expect(\n        (partMeta.cache_control as Record<string, unknown>).type\n      ).toBe(\"ephemeral\");\n    });\n\n    it(\"handles the exact bug reproduction scenario from issue\", () => {\n      const payload = {\n        model: \"claude-opus-4-6-thinking-medium\",\n        contents: [\n          {\n            role: \"user\",\n            parts: [\n              {\n                text: \"Check how much disk space is available using df -h. Think about which filesystems are most utilized.\",\n              },\n            ],\n          },\n          {\n            role: \"model\",\n            parts: [\n              {\n                thought: true,\n                text: \"Let me analyze the disk space request. The user wants to see disk usage and understand filesystem utilization patterns...\",\n                thoughtSignature:\n                  \"EsgQCsUQAXLI2nybuafAE150LGTo2r78VeryLongSignatureStringThatExceeds50Characters\",\n              },\n              {\n                functionCall: {\n                  name: \"Bash\",\n                  args: {\n                    command: \"df -h\",\n                    description: \"Check disk space availability\",\n                  },\n                },\n                metadata: {\n                  google: {\n                    thoughtSignature:\n                      \"EsgQCsUQAXLI2nybuafAE150LGTo2r78VeryLongSignatureStringThatExceeds50Characters\",\n                  },\n                },\n              },\n            ],\n          },\n          {\n            role: \"function\",\n            parts: [\n              {\n                functionResponse: {\n                  name: \"Bash\",\n                  response: {\n                    output:\n                      \"Filesystem      Size  Used Avail Use% Mounted on\\noverlay          59G   37G   20G  65% /\\ntmpfs            64M     0   64M   0% /dev\\n\",\n                  },\n                },\n              },\n            ],\n          },\n          {\n            role: \"model\",\n            parts: [\n              {\n                text: \"Based on the disk space analysis, the root overlay filesystem is 65% utilized with 37G used out of 59G total.\",\n              },\n            ],\n          },\n          {\n            role: \"user\",\n            parts: [{ text: \"Now check memory usage with free -h\" }],\n          },\n        ],\n      };\n\n      const result = sanitizeCrossModelPayload(payload, {\n        targetModel: \"claude-opus-4-6-thinking-medium\",\n      });\n\n      const sanitized = result.payload as typeof payload;\n\n      const thinkingPart = sanitized.contents[1]!.parts![0] as Record<string, unknown>;\n      expect(thinkingPart.thoughtSignature).toBeUndefined();\n      expect(thinkingPart.thought).toBe(true);\n      expect(thinkingPart.text).toContain(\"analyze the disk space\");\n\n      const toolPart = sanitized.contents[1]!.parts![1] as Record<string, unknown>;\n      expect(toolPart.metadata).toBeUndefined();\n      expect(\n        (toolPart.functionCall as Record<string, unknown>).name\n      ).toBe(\"Bash\");\n\n      expect(result.signaturesStripped).toBe(2);\n    });\n  });\n\n  describe(\"Claude → Gemini model switch\", () => {\n    it(\"sanitizes Claude thinking blocks when preparing Gemini request\", () => {\n      const payload = {\n        extra_body: {\n          messages: [\n            {\n              role: \"assistant\",\n              content: [\n                {\n                  type: \"thinking\",\n                  thinking: \"Analyzing the request...\",\n                  signature:\n                    \"claude-signature-abc123VeryLongSignatureStringThatExceeds50Characters\",\n                },\n                {\n                  type: \"tool_use\",\n                  id: \"tool_1\",\n                  name: \"bash\",\n                  input: { command: \"ls\" },\n                },\n              ],\n            },\n          ],\n        },\n      };\n\n      const result = sanitizeCrossModelPayload(payload, {\n        targetModel: \"gemini-3-pro-low\",\n      });\n\n      const sanitized = result.payload as typeof payload;\n      const content = sanitized.extra_body!.messages![0]!.content;\n      const thinkingBlock = content.find(\n        (c: Record<string, unknown>) => c.type === \"thinking\"\n      ) as Record<string, unknown>;\n\n      expect(thinkingBlock.signature).toBeUndefined();\n      expect(thinkingBlock.thinking).toBe(\"Analyzing the request...\");\n\n      const toolBlock = content.find(\n        (c: Record<string, unknown>) => c.type === \"tool_use\"\n      ) as Record<string, unknown>;\n      expect(toolBlock.name).toBe(\"bash\");\n    });\n\n    it(\"strips redacted_thinking blocks\", () => {\n      const payload = {\n        messages: [\n          {\n            role: \"assistant\",\n            content: [\n              {\n                type: \"redacted_thinking\",\n                data: \"encrypted_data_here\",\n                signature:\n                  \"redacted-sig-VeryLongSignatureStringThatExceeds50Characters\",\n              },\n              { type: \"text\", text: \"Here is my response\" },\n            ],\n          },\n        ],\n      };\n\n      const result = sanitizeCrossModelPayload(payload, {\n        targetModel: \"gemini-3-flash\",\n      });\n\n      const sanitized = result.payload as typeof payload;\n      const redactedBlock = sanitized.messages![0]!.content![0] as Record<\n        string,\n        unknown\n      >;\n\n      expect(redactedBlock.signature).toBeUndefined();\n      expect(redactedBlock.type).toBe(\"redacted_thinking\");\n    });\n  });\n\n  describe(\"Same model family - no sanitization needed\", () => {\n    it(\"preserves Gemini signatures when staying on Gemini\", () => {\n      const payload = {\n        contents: [\n          {\n            role: \"model\",\n            parts: [\n              {\n                thought: true,\n                text: \"thinking...\",\n                thoughtSignature: \"valid-gemini-sig\",\n              },\n            ],\n          },\n        ],\n      };\n\n      const result = sanitizeCrossModelPayload(payload, {\n        targetModel: \"gemini-3-flash\",\n      });\n\n      const sanitized = result.payload as typeof payload;\n      expect(\n        (sanitized.contents![0]!.parts![0] as Record<string, unknown>)\n          .thoughtSignature\n      ).toBe(\"valid-gemini-sig\");\n      expect(result.modified).toBe(false);\n    });\n\n    it(\"preserves Claude signatures when staying on Claude\", () => {\n      const payload = {\n        messages: [\n          {\n            role: \"assistant\",\n            content: [\n              {\n                type: \"thinking\",\n                thinking: \"analyzing...\",\n                signature: \"valid-claude-sig\",\n              },\n            ],\n          },\n        ],\n      };\n\n      const result = sanitizeCrossModelPayload(payload, {\n        targetModel: \"claude-opus-4-6-thinking-low\",\n      });\n\n      const sanitized = result.payload as typeof payload;\n      expect(\n        (sanitized.messages![0]!.content![0] as Record<string, unknown>).signature\n      ).toBe(\"valid-claude-sig\");\n      expect(result.modified).toBe(false);\n    });\n  });\n\n  describe(\"Model family detection\", () => {\n    it(\"correctly identifies Gemini models\", () => {\n      expect(getModelFamily(\"gemini-3-pro-low\")).toBe(\"gemini\");\n      expect(getModelFamily(\"gemini-3-flash\")).toBe(\"gemini\");\n      expect(getModelFamily(\"gemini-2.5-pro\")).toBe(\"gemini\");\n      expect(getModelFamily(\"gemini-3-pro-high\")).toBe(\"gemini\");\n    });\n\n    it(\"correctly identifies Claude models\", () => {\n      expect(getModelFamily(\"claude-opus-4-6-thinking-medium\")).toBe(\"claude\");\n      expect(getModelFamily(\"claude-sonnet-4-6\")).toBe(\"claude\");\n      expect(getModelFamily(\"claude-sonnet-4\")).toBe(\"claude\");\n      expect(getModelFamily(\"claude-3-opus\")).toBe(\"claude\");\n    });\n\n    it(\"returns unknown for unrecognized models\", () => {\n      expect(getModelFamily(\"gpt-4\")).toBe(\"unknown\");\n      expect(getModelFamily(\"llama-3\")).toBe(\"unknown\");\n    });\n  });\n\n  describe(\"Edge cases\", () => {\n    it(\"handles empty payloads\", () => {\n      const result = sanitizeCrossModelPayload(\n        {},\n        { targetModel: \"claude-sonnet-4\" }\n      );\n      expect(result.modified).toBe(false);\n      expect(result.signaturesStripped).toBe(0);\n    });\n\n    it(\"handles null/undefined parts gracefully\", () => {\n      const payload = {\n        contents: [\n          { role: \"user\", parts: null },\n          { role: \"model\", parts: undefined },\n          { role: \"model\" },\n        ],\n      };\n\n      const result = sanitizeCrossModelPayload(payload, {\n        targetModel: \"claude-sonnet-4\",\n      });\n\n      expect(result.modified).toBe(false);\n    });\n\n    it(\"handles wrapped requests array (batch format)\", () => {\n      const payload = {\n        requests: [\n          {\n            contents: [\n              {\n                role: \"model\",\n                parts: [\n                  {\n                    thoughtSignature: \"sig1\",\n                    thought: true,\n                    text: \"thinking\",\n                  },\n                ],\n              },\n            ],\n          },\n          {\n            contents: [\n              {\n                role: \"model\",\n                parts: [\n                  {\n                    metadata: { google: { thoughtSignature: \"sig2\" } },\n                    functionCall: { name: \"test\" },\n                  },\n                ],\n              },\n            ],\n          },\n        ],\n      };\n\n      const result = sanitizeCrossModelPayload(payload, {\n        targetModel: \"claude-sonnet-4-6\",\n      });\n\n      const sanitized = result.payload as typeof payload;\n\n      expect(\n        (sanitized.requests![0]!.contents![0]!.parts![0] as Record<string, unknown>)\n          .thoughtSignature\n      ).toBeUndefined();\n      expect(\n        (sanitized.requests![1]!.contents![0]!.parts![0] as Record<string, unknown>)\n          .metadata\n      ).toBeUndefined();\n      expect(result.signaturesStripped).toBe(2);\n    });\n\n    it(\"handles unknown target model by skipping sanitization\", () => {\n      const payload = {\n        contents: [\n          {\n            role: \"model\",\n            parts: [{ thoughtSignature: \"sig\", thought: true, text: \"hi\" }],\n          },\n        ],\n      };\n\n      const result = sanitizeCrossModelPayload(payload, {\n        targetModel: \"gpt-4-turbo\",\n      });\n\n      const sanitized = result.payload as typeof payload;\n      expect(\n        (sanitized.contents![0]!.parts![0] as Record<string, unknown>)\n          .thoughtSignature\n      ).toBe(\"sig\");\n      expect(result.modified).toBe(false);\n    });\n  });\n});\n"
  },
  {
    "path": "src/plugin/debug.test.ts",
    "content": "import { afterEach, beforeEach, describe, expect, it, vi } from \"vitest\"\nimport { DEFAULT_CONFIG } from \"./config\"\n\nconst { ensureGitignoreSyncMock } = vi.hoisted(() => ({\n  ensureGitignoreSyncMock: vi.fn(),\n}))\n\nvi.mock(\"./storage\", () => ({\n  ensureGitignoreSync: ensureGitignoreSyncMock,\n}))\n\ndescribe(\"debug sink policy\", () => {\n  let originalDebugEnv: string | undefined\n  let originalDebugTuiEnv: string | undefined\n\n  beforeEach(() => {\n    vi.resetModules()\n    originalDebugEnv = process.env.OPENCODE_ANTIGRAVITY_DEBUG\n    originalDebugTuiEnv = process.env.OPENCODE_ANTIGRAVITY_DEBUG_TUI\n    delete process.env.OPENCODE_ANTIGRAVITY_DEBUG\n    delete process.env.OPENCODE_ANTIGRAVITY_DEBUG_TUI\n    ensureGitignoreSyncMock.mockReset()\n  })\n\n  afterEach(() => {\n    if (originalDebugEnv === undefined) {\n      delete process.env.OPENCODE_ANTIGRAVITY_DEBUG\n    } else {\n      process.env.OPENCODE_ANTIGRAVITY_DEBUG = originalDebugEnv\n    }\n\n    if (originalDebugTuiEnv === undefined) {\n      delete process.env.OPENCODE_ANTIGRAVITY_DEBUG_TUI\n    } else {\n      process.env.OPENCODE_ANTIGRAVITY_DEBUG_TUI = originalDebugTuiEnv\n    }\n  })\n\n  it(\"keeps debug_tui independent from debug in config\", async () => {\n    const { initializeDebug, isDebugEnabled, isDebugTuiEnabled, getLogFilePath } = await import(\"./debug\")\n\n    initializeDebug({\n      ...DEFAULT_CONFIG,\n      debug: false,\n      debug_tui: true,\n    })\n\n    expect(isDebugEnabled()).toBe(false)\n    expect(isDebugTuiEnabled()).toBe(true)\n    expect(getLogFilePath()).toBeUndefined()\n  })\n\n  it(\"keeps debug_tui independent from debug in env fallback\", async () => {\n    process.env.OPENCODE_ANTIGRAVITY_DEBUG = \"0\"\n    process.env.OPENCODE_ANTIGRAVITY_DEBUG_TUI = \"1\"\n\n    const { isDebugEnabled, isDebugTuiEnabled, getLogFilePath } = await import(\"./debug\")\n\n    expect(isDebugEnabled()).toBe(false)\n    expect(isDebugTuiEnabled()).toBe(true)\n    expect(getLogFilePath()).toBeUndefined()\n  })\n\n  it(\"keeps file debug enabled without TUI when only debug is true\", async () => {\n    const { initializeDebug, isDebugEnabled, isDebugTuiEnabled, getLogFilePath } = await import(\"./debug\")\n\n    initializeDebug({\n      ...DEFAULT_CONFIG,\n      debug: true,\n      debug_tui: false,\n      log_dir: \"/tmp/opencode-antigravity-debug-tests\",\n    })\n\n    expect(isDebugEnabled()).toBe(true)\n    expect(isDebugTuiEnabled()).toBe(false)\n    expect(getLogFilePath()).toContain(\"antigravity-debug-\")\n  })\n})\n"
  },
  {
    "path": "src/plugin/debug.ts",
    "content": "import { createWriteStream, mkdirSync, readdirSync, statSync, unlinkSync } from \"node:fs\";\nimport { join } from \"node:path\";\nimport { env } from \"node:process\";\nimport { homedir } from \"node:os\";\nimport type { AntigravityConfig } from \"./config\";\nimport {\n  deriveDebugPolicy,\n  formatAccountContextLabel,\n  formatAccountLabel,\n  formatBodyPreviewForLog,\n  formatErrorForLog,\n  isTruthyFlag,\n  truncateTextForLog,\n} from \"./logging-utils\";\nimport { ensureGitignoreSync } from \"./storage\";\n\nconst MAX_BODY_PREVIEW_CHARS = 12000;\nconst MAX_BODY_LOG_CHARS = 50000;\n\nexport const DEBUG_MESSAGE_PREFIX = \"[opencode-antigravity-auth debug]\";\n\n// =============================================================================\n// Debug State (lazily initialized with config)\n// =============================================================================\n\ninterface DebugState {\n  debugEnabled: boolean;\n  debugTuiEnabled: boolean;\n  logFilePath: string | undefined;\n  logWriter: (line: string) => void;\n}\n\nlet debugState: DebugState | null = null;\n\n/**\n * Get the OS-specific config directory.\n */\nfunction getConfigDir(): string {\n  const platform = process.platform;\n  if (platform === \"win32\") {\n    return join(env.APPDATA || join(homedir(), \"AppData\", \"Roaming\"), \"opencode\");\n  }\n  const xdgConfig = env.XDG_CONFIG_HOME || join(homedir(), \".config\");\n  return join(xdgConfig, \"opencode\");\n}\n\n/**\n * Returns the logs directory, creating it if needed.\n */\nfunction getLogsDir(customLogDir?: string): string {\n  const logsDir = customLogDir || join(getConfigDir(), \"antigravity-logs\");\n\n  try {\n    mkdirSync(logsDir, { recursive: true });\n  } catch {\n    // Directory may already exist or we don't have permission\n  }\n\n  return logsDir;\n}\n\n/**\n * Builds a timestamped log file path.\n */\nfunction createLogFilePath(customLogDir?: string): string {\n  const logsDir = getLogsDir(customLogDir);\n  cleanupOldLogs(logsDir, 25);\n  const timestamp = new Date().toISOString().replace(/[:.]/g, \"-\");\n  return join(logsDir, `antigravity-debug-${timestamp}.log`);\n}\n\n/**\n * Cleans up old log files, keeping only the most recent maxFiles.\n */\nfunction cleanupOldLogs(logsDir: string, maxFiles: number): void {\n  try {\n    const files = readdirSync(logsDir)\n      .filter((file) => file.startsWith(\"antigravity-debug-\") && file.endsWith(\".log\"))\n      .map((file) => join(logsDir, file));\n\n    if (files.length <= maxFiles) {\n      return;\n    }\n\n    const sortedFiles = files\n      .map((file) => ({\n        file,\n        mtime: statSync(file).mtimeMs,\n      }))\n      .sort((a, b) => b.mtime - a.mtime);\n\n    for (let i = maxFiles; i < sortedFiles.length; i++) {\n      try {\n        unlinkSync(sortedFiles[i]!.file);\n      } catch {\n        // Ignore deletion errors\n      }\n    }\n  } catch {\n    // Ignore directory read errors\n  }\n}\n\n/**\n * Creates a log writer function that writes to a file.\n */\nfunction createLogWriter(filePath?: string): (line: string) => void {\n  if (!filePath) {\n    return () => {};\n  }\n\n  try {\n    const stream = createWriteStream(filePath, { flags: \"a\" });\n    stream.on(\"error\", () => {});\n    return (line: string) => {\n      const timestamp = new Date().toISOString();\n      const formatted = `[${timestamp}] ${line}`;\n      stream.write(`${formatted}\\n`);\n    };\n  } catch {\n    return () => {};\n  }\n}\n\n/**\n * Initialize or reinitialize debug state with the given config.\n * Call this once at plugin startup after loading config.\n */\nexport function initializeDebug(config: AntigravityConfig): void {\n  // Config takes precedence, but env var can force enable for debugging\n  const envDebugFlag = env.OPENCODE_ANTIGRAVITY_DEBUG ?? \"\";\n  const { debugEnabled } = deriveDebugPolicy({\n    configDebug: config.debug,\n    configDebugTui: config.debug_tui,\n    envDebugFlag,\n    envDebugTuiFlag: env.OPENCODE_ANTIGRAVITY_DEBUG_TUI,\n  });\n  const debugTuiEnabled = config.debug_tui || isTruthyFlag(env.OPENCODE_ANTIGRAVITY_DEBUG_TUI);\n  const logFilePath = debugEnabled ? createLogFilePath(config.log_dir) : undefined;\n  const logWriter = createLogWriter(logFilePath);\n\n  if (debugEnabled) {\n    ensureGitignoreSync(getConfigDir());\n  }\n\n  debugState = {\n    debugEnabled,\n    debugTuiEnabled,\n    logFilePath,\n    logWriter,\n  };\n}\n\n/**\n * Get the current debug state, initializing with defaults if needed.\n * This allows the module to work even before initializeDebug is called.\n */\nfunction getDebugState(): DebugState {\n  if (!debugState) {\n    // Fallback to env-based initialization for backward compatibility\n    const { debugEnabled } = deriveDebugPolicy({\n      configDebug: false,\n      configDebugTui: false,\n      envDebugFlag: env.OPENCODE_ANTIGRAVITY_DEBUG,\n      envDebugTuiFlag: env.OPENCODE_ANTIGRAVITY_DEBUG_TUI,\n    });\n    const debugTuiEnabled = isTruthyFlag(env.OPENCODE_ANTIGRAVITY_DEBUG_TUI);\n    const logFilePath = debugEnabled ? createLogFilePath() : undefined;\n    const logWriter = createLogWriter(logFilePath);\n\n    debugState = {\n      debugEnabled,\n      debugTuiEnabled,\n      logFilePath,\n      logWriter,\n    };\n  }\n  return debugState;\n}\n\n// =============================================================================\n// Public API\n// =============================================================================\n\nexport function isDebugEnabled(): boolean {\n  return getDebugState().debugEnabled;\n}\n\nexport function isDebugTuiEnabled(): boolean {\n  return getDebugState().debugTuiEnabled;\n}\n\nexport function getLogFilePath(): string | undefined {\n  return getDebugState().logFilePath;\n}\n\nexport interface AntigravityDebugContext {\n  id: string;\n  streaming: boolean;\n  startedAt: number;\n}\n\ninterface AntigravityDebugRequestMeta {\n  originalUrl: string;\n  resolvedUrl: string;\n  method?: string;\n  headers?: HeadersInit;\n  body?: BodyInit | null;\n  streaming: boolean;\n  projectId?: string;\n}\n\ninterface AntigravityDebugResponseMeta {\n  body?: string;\n  note?: string;\n  error?: unknown;\n  headersOverride?: HeadersInit;\n}\n\nlet requestCounter = 0;\n\n/**\n * Begins a debug trace for an Antigravity request.\n */\nexport function startAntigravityDebugRequest(meta: AntigravityDebugRequestMeta): AntigravityDebugContext | null {\n  const state = getDebugState();\n  if (!state.debugEnabled) {\n    return null;\n  }\n\n  const id = `ANTIGRAVITY-${++requestCounter}`;\n  const method = meta.method ?? \"GET\";\n  logDebug(`[Antigravity Debug ${id}] pid=${process.pid} ${method} ${meta.resolvedUrl}`);\n  if (meta.originalUrl && meta.originalUrl !== meta.resolvedUrl) {\n    logDebug(`[Antigravity Debug ${id}] Original URL: ${meta.originalUrl}`);\n  }\n  if (meta.projectId) {\n    logDebug(`[Antigravity Debug ${id}] Project: ${meta.projectId}`);\n  }\n  logDebug(`[Antigravity Debug ${id}] Streaming: ${meta.streaming ? \"yes\" : \"no\"}`);\n  logDebug(`[Antigravity Debug ${id}] Headers: ${JSON.stringify(maskHeaders(meta.headers))}`);\n  const bodyPreview = formatBodyPreviewForLog(meta.body, MAX_BODY_PREVIEW_CHARS);\n  if (bodyPreview) {\n    logDebug(`[Antigravity Debug ${id}] Body Preview: ${bodyPreview}`);\n  }\n\n  return { id, streaming: meta.streaming, startedAt: Date.now() };\n}\n\n/**\n * Logs response details for a previously started debug trace.\n */\nexport function logAntigravityDebugResponse(\n  context: AntigravityDebugContext | null | undefined,\n  response: Response,\n  meta: AntigravityDebugResponseMeta = {},\n): void {\n  const state = getDebugState();\n  if (!state.debugEnabled || !context) {\n    return;\n  }\n\n  const durationMs = Date.now() - context.startedAt;\n  logDebug(\n    `[Antigravity Debug ${context.id}] Response ${response.status} ${response.statusText} (${durationMs}ms)`,\n  );\n  logDebug(\n    `[Antigravity Debug ${context.id}] Response Headers: ${JSON.stringify(\n      maskHeaders(meta.headersOverride ?? response.headers),\n    )}`,\n  );\n\n  if (meta.note) {\n    logDebug(`[Antigravity Debug ${context.id}] Note: ${meta.note}`);\n  }\n\n  if (meta.error) {\n    logDebug(`[Antigravity Debug ${context.id}] Error: ${formatErrorForLog(meta.error)}`);\n  }\n\n  if (meta.body) {\n    logDebug(\n      `[Antigravity Debug ${context.id}] Response Body Preview: ${truncateTextForLog(meta.body, MAX_BODY_PREVIEW_CHARS)}`,\n    );\n  }\n}\n\n/**\n * Obscures sensitive headers and returns a plain object for logging.\n */\nfunction maskHeaders(headers?: HeadersInit | Headers): Record<string, string> {\n  if (!headers) {\n    return {};\n  }\n\n  const result: Record<string, string> = {};\n  const parsed = headers instanceof Headers ? headers : new Headers(headers);\n  parsed.forEach((value, key) => {\n    if (key.toLowerCase() === \"authorization\") {\n      result[key] = \"[redacted]\";\n    } else {\n      result[key] = value;\n    }\n  });\n  return result;\n}\n\n/**\n * Writes a single debug line using the configured writer.\n */\nfunction logDebug(line: string): void {\n  getDebugState().logWriter(line);\n}\n\nfunction runWithDebugEnabled(action: () => void): void {\n  if (!getDebugState().debugEnabled) return;\n  action();\n}\n\nexport interface AccountDebugInfo {\n  index: number;\n  email?: string;\n  family: string;\n  totalAccounts: number;\n  rateLimitState?: { claude?: number; gemini?: number };\n}\n\nexport function logAccountContext(label: string, info: AccountDebugInfo): void {\n  runWithDebugEnabled(() => {\n    const accountLabel = formatAccountContextLabel(info.email, info.index);\n\n    const indexLabel = info.index >= 0 ? `${info.index + 1}/${info.totalAccounts}` : `-/${info.totalAccounts}`;\n\n    let rateLimitInfo = \"\";\n    if (info.rateLimitState && Object.keys(info.rateLimitState).length > 0) {\n      const now = Date.now();\n      const activeRateLimits: Record<string, string> = {};\n      for (const [key, resetTime] of Object.entries(info.rateLimitState)) {\n        if (typeof resetTime === \"number\" && resetTime > now) {\n          const remainingSec = Math.ceil((resetTime - now) / 1000);\n          activeRateLimits[key] = `${remainingSec}s`;\n        }\n      }\n      if (Object.keys(activeRateLimits).length > 0) {\n        rateLimitInfo = ` rateLimits=${JSON.stringify(activeRateLimits)}`;\n      }\n    }\n\n    logDebug(`[Account] ${label}: ${accountLabel} (${indexLabel}) family=${info.family}${rateLimitInfo}`);\n  });\n}\n\nexport function logRateLimitEvent(\n  accountIndex: number,\n  email: string | undefined,\n  family: string,\n  status: number,\n  retryAfterMs: number,\n  bodyInfo: { message?: string; quotaResetTime?: string; retryDelayMs?: number | null; reason?: string },\n): void {\n  runWithDebugEnabled(() => {\n    const accountLabel = formatAccountLabel(email, accountIndex);\n    logDebug(`[RateLimit] ${status} on ${accountLabel} family=${family} retryAfterMs=${retryAfterMs}`);\n    if (bodyInfo.message) {\n      logDebug(`[RateLimit] message: ${bodyInfo.message}`);\n    }\n    if (bodyInfo.quotaResetTime) {\n      logDebug(`[RateLimit] quotaResetTime: ${bodyInfo.quotaResetTime}`);\n    }\n    if (bodyInfo.retryDelayMs !== undefined && bodyInfo.retryDelayMs !== null) {\n      logDebug(`[RateLimit] body retryDelayMs: ${bodyInfo.retryDelayMs}`);\n    }\n    if (bodyInfo.reason) {\n      logDebug(`[RateLimit] reason: ${bodyInfo.reason}`);\n    }\n  });\n}\n\nexport function logRateLimitSnapshot(\n  family: string,\n  accounts: Array<{ index: number; email?: string; rateLimitResetTimes?: { claude?: number; gemini?: number } }>,\n): void {\n  runWithDebugEnabled(() => {\n    const now = Date.now();\n    const entries = accounts.map((account) => {\n      const label = formatAccountLabel(account.email, account.index);\n      const reset = account.rateLimitResetTimes?.[family as \"claude\" | \"gemini\"];\n      if (typeof reset !== \"number\") {\n        return `${label}=ready`;\n      }\n      const remaining = Math.max(0, reset - now);\n      const seconds = Math.ceil(remaining / 1000);\n      return `${label}=wait ${seconds}s`;\n    });\n    logDebug(`[RateLimit] snapshot family=${family} ${entries.join(\" | \")}`);\n  });\n}\n\nexport async function logResponseBody(\n  context: AntigravityDebugContext | null | undefined,\n  response: Response,\n  status: number,\n): Promise<string | undefined> {\n  const state = getDebugState();\n  if (!state.debugEnabled || !context) return undefined;\n\n  try {\n    const text = await response.clone().text();\n    const preview = truncateTextForLog(text, MAX_BODY_LOG_CHARS);\n    logDebug(`[Antigravity Debug ${context.id}] Response Body (${status}): ${preview}`);\n    return text;\n  } catch (e) {\n    logDebug(`[Antigravity Debug ${context.id}] Failed to read response body: ${formatErrorForLog(e)}`);\n    return undefined;\n  }\n}\n\nexport function logModelFamily(url: string, extractedModel: string | null, family: string): void {\n  runWithDebugEnabled(() => {\n    logDebug(`[ModelFamily] url=${url} model=${extractedModel ?? \"unknown\"} family=${family}`);\n  });\n}\n\nexport function debugLogToFile(message: string): void {\n  runWithDebugEnabled(() => {\n    logDebug(message);\n  });\n}\n\n/**\n * Logs a toast message to the debug file.\n * This helps correlate what the user saw with debug events.\n */\nexport function logToast(message: string, variant: \"info\" | \"warning\" | \"success\" | \"error\"): void {\n  runWithDebugEnabled(() => {\n    const variantLabel = variant.toUpperCase();\n    logDebug(`[Toast/${variantLabel}] ${message}`);\n  });\n}\n\n/**\n * Logs retry attempt information.\n * @param maxAttempts - Use -1 for unlimited retries\n */\nexport function logRetryAttempt(\n  attempt: number,\n  maxAttempts: number,\n  reason: string,\n  delayMs?: number,\n): void {\n  runWithDebugEnabled(() => {\n    const delayInfo = delayMs !== undefined ? ` delay=${delayMs}ms` : \"\";\n    const maxInfo = maxAttempts < 0 ? \"∞\" : maxAttempts.toString();\n    logDebug(`[Retry] Attempt ${attempt}/${maxInfo} reason=${reason}${delayInfo}`);\n  });\n}\n\n/**\n * Logs cache hit/miss information from response usage metadata.\n */\nexport function logCacheStats(\n  model: string,\n  cacheReadTokens: number,\n  cacheWriteTokens: number,\n  totalInputTokens: number,\n): void {\n  runWithDebugEnabled(() => {\n    const cacheHitRate = totalInputTokens > 0 \n      ? Math.round((cacheReadTokens / totalInputTokens) * 100) \n      : 0;\n    const status = cacheReadTokens > 0 ? \"HIT\" : (cacheWriteTokens > 0 ? \"WRITE\" : \"MISS\");\n    logDebug(`[Cache] ${status} model=${model} read=${cacheReadTokens} write=${cacheWriteTokens} total=${totalInputTokens} hitRate=${cacheHitRate}%`);\n  });\n}\n\n/**\n * Logs quota status for an account.\n */\nexport function logQuotaStatus(\n  accountEmail: string | undefined,\n  accountIndex: number,\n  quotaPercent: number,\n  family?: string,\n): void {\n  runWithDebugEnabled(() => {\n    const accountLabel = formatAccountLabel(accountEmail, accountIndex);\n    const familyInfo = family ? ` family=${family}` : \"\";\n    const status = quotaPercent <= 0 ? \"EXHAUSTED\" : quotaPercent < 20 ? \"LOW\" : \"OK\";\n    logDebug(`[Quota] ${accountLabel} remaining=${quotaPercent.toFixed(1)}% status=${status}${familyInfo}`);\n  });\n}\n\n/**\n * Logs background quota fetch events.\n */\nexport function logQuotaFetch(\n  event: \"start\" | \"complete\" | \"error\",\n  accountCount?: number,\n  details?: string,\n): void {\n  runWithDebugEnabled(() => {\n    const countInfo = accountCount !== undefined ? ` accounts=${accountCount}` : \"\";\n    const detailsInfo = details ? ` ${details}` : \"\";\n    logDebug(`[QuotaFetch] ${event.toUpperCase()}${countInfo}${detailsInfo}`);\n  });\n}\n\n/**\n * Logs which model is being used for a request.\n */\nexport function logModelUsed(\n  requestedModel: string,\n  actualModel: string,\n  accountEmail?: string,\n): void {\n  runWithDebugEnabled(() => {\n    const accountInfo = accountEmail ? ` account=${accountEmail}` : \"\";\n    if (requestedModel !== actualModel) {\n      logDebug(`[Model] requested=${requestedModel} actual=${actualModel}${accountInfo}`);\n    } else {\n      logDebug(`[Model] ${actualModel}${accountInfo}`);\n    }\n  });\n}\n"
  },
  {
    "path": "src/plugin/errors.ts",
    "content": "/**\n * Custom error types for opencode-antigravity-auth plugin.\n * \n * Ported from LLM-API-Key-Proxy for robust error handling.\n */\n\n/**\n * Error thrown when Antigravity returns an empty response after retry attempts.\n * \n * Empty responses can occur when:\n * - The model has no candidates/choices\n * - The response body is empty or malformed\n * - A temporary service issue prevents generation\n */\nexport class EmptyResponseError extends Error {\n  readonly provider: string;\n  readonly model: string;\n  readonly attempts: number;\n\n  constructor(\n    provider: string,\n    model: string,\n    attempts: number,\n    message?: string,\n  ) {\n    super(\n      message ??\n        `The model returned an empty response after ${attempts} attempts. ` +\n        `This may indicate a temporary service issue. Please try again.`,\n    );\n    this.name = \"EmptyResponseError\";\n    this.provider = provider;\n    this.model = model;\n    this.attempts = attempts;\n  }\n}\n\n/**\n * Error thrown when tool ID matching fails and cannot be recovered.\n */\nexport class ToolIdMismatchError extends Error {\n  readonly expectedIds: string[];\n  readonly foundIds: string[];\n\n  constructor(expectedIds: string[], foundIds: string[], message?: string) {\n    super(\n      message ??\n        `Tool ID mismatch: expected [${expectedIds.join(\", \")}] but found [${foundIds.join(\", \")}]`,\n    );\n    this.name = \"ToolIdMismatchError\";\n    this.expectedIds = expectedIds;\n    this.foundIds = foundIds;\n  }\n}\n"
  },
  {
    "path": "src/plugin/fingerprint.ts",
    "content": "/**\n * Device Fingerprint Generator for Rate Limit Mitigation\n *\n * Ported from antigravity-claude-proxy PR #170\n * https://github.com/badrisnarayanan/antigravity-claude-proxy/pull/170\n *\n * Generates randomized device fingerprints to help distribute API usage\n * across different apparent device identities.\n */\n\nimport * as crypto from \"node:crypto\";\nimport * as os from \"node:os\";\nimport { getAntigravityVersion } from \"../constants\";\n\nconst OS_VERSIONS: Record<string, string[]> = {\n  darwin: [\"10.15.7\", \"11.6.8\", \"12.6.3\", \"13.5.2\", \"14.2.1\", \"14.5\"],\n  win32: [\"10.0.19041\", \"10.0.19042\", \"10.0.19043\", \"10.0.22000\", \"10.0.22621\", \"10.0.22631\"],\n  linux: [\"5.15.0\", \"5.19.0\", \"6.1.0\", \"6.2.0\", \"6.5.0\", \"6.6.0\"],\n};\n\nconst ARCHITECTURES = [\"x64\", \"arm64\"];\n\nconst IDE_TYPES = [\n  \"ANTIGRAVITY\",\n] as const;\n\nconst PLATFORMS = [\n  \"WINDOWS\",\n  \"MACOS\",\n] as const;\n\nconst SDK_CLIENTS = [\n  \"google-cloud-sdk vscode_cloudshelleditor/0.1\",\n  \"google-cloud-sdk vscode/1.86.0\",\n  \"google-cloud-sdk vscode/1.87.0\",\n  \"google-cloud-sdk vscode/1.96.0\",\n];\n\nexport interface ClientMetadata {\n  ideType: string;\n  platform: string;\n  pluginType: string;\n}\n\nexport interface Fingerprint {\n  deviceId: string;\n  sessionToken: string;\n  userAgent: string;\n  apiClient: string;\n  clientMetadata: ClientMetadata;\n  createdAt: number;\n  /** @deprecated Kept for backward compat with stored fingerprints */\n  quotaUser?: string;\n}\n\n/**\n * Fingerprint version for history tracking.\n * Stores a snapshot of a fingerprint with metadata about when/why it was saved.\n */\nexport interface FingerprintVersion {\n  fingerprint: Fingerprint;\n  timestamp: number;\n  reason: 'initial' | 'regenerated' | 'restored';\n}\n\n/** Maximum number of fingerprint versions to keep per account */\nexport const MAX_FINGERPRINT_HISTORY = 5;\n\nexport interface FingerprintHeaders {\n  \"User-Agent\": string;\n}\n\nfunction randomFrom<T>(arr: readonly T[]): T {\n  return arr[Math.floor(Math.random() * arr.length)]!;\n}\n\nfunction generateDeviceId(): string {\n  return crypto.randomUUID();\n}\n\nfunction generateSessionToken(): string {\n  return crypto.randomBytes(16).toString(\"hex\");\n}\n\n/**\n * Generate a randomized device fingerprint.\n * Each fingerprint represents a unique \"device\" identity.\n */\nexport function generateFingerprint(): Fingerprint {\n  const platform = randomFrom([\"darwin\", \"win32\"] as const);\n  const arch = randomFrom(ARCHITECTURES);\n  const osVersion = randomFrom(OS_VERSIONS[platform] ?? OS_VERSIONS.darwin!);\n\n  const matchingPlatform =\n    platform === \"win32\"\n      ? \"WINDOWS\"\n      : \"MACOS\";\n\n  return {\n    deviceId: generateDeviceId(),\n    sessionToken: generateSessionToken(),\n    userAgent: `antigravity/${getAntigravityVersion()} ${platform}/${arch}`,\n    apiClient: randomFrom(SDK_CLIENTS),\n    clientMetadata: {\n      ideType: randomFrom(IDE_TYPES),\n      platform: matchingPlatform,\n      pluginType: \"GEMINI\",\n    },\n    createdAt: Date.now(),\n  };\n}\n\n/**\n * Collect fingerprint based on actual current system.\n * Uses real OS info instead of randomized values.\n */\nexport function collectCurrentFingerprint(): Fingerprint {\n  const platform = os.platform();\n  const arch = os.arch();\n\n  const matchingPlatform =\n    platform === \"win32\"\n      ? \"WINDOWS\"\n      : \"MACOS\";\n\n  return {\n    deviceId: generateDeviceId(),\n    sessionToken: generateSessionToken(),\n    userAgent: `antigravity/${getAntigravityVersion()} ${platform}/${arch}`,\n    apiClient: \"google-cloud-sdk vscode_cloudshelleditor/0.1\",\n    clientMetadata: {\n      ideType: \"ANTIGRAVITY\",\n      platform: matchingPlatform,\n      pluginType: \"GEMINI\",\n    },\n    createdAt: Date.now(),\n  };\n}\n\n/**\n * Update the version in a fingerprint's userAgent to match the current runtime version.\n * Called after version fetcher resolves so saved fingerprints always carry the latest version.\n * Returns true if the userAgent was changed.\n */\nexport function updateFingerprintVersion(fingerprint: Fingerprint): boolean {\n  const currentVersion = getAntigravityVersion();\n  const versionPattern = /^(antigravity\\/)([\\d.]+)/;\n  const match = fingerprint.userAgent.match(versionPattern);\n\n  if (!match || match[2] === currentVersion) {\n    return false;\n  }\n\n  fingerprint.userAgent = fingerprint.userAgent.replace(versionPattern, `$1${currentVersion}`);\n  return true;\n}\n\n/**\n * Build HTTP headers from a fingerprint object.\n * These headers are used to identify the \"device\" making API requests.\n */\nexport function buildFingerprintHeaders(fingerprint: Fingerprint | null): Partial<FingerprintHeaders> {\n  if (!fingerprint) {\n    return {};\n  }\n\n  return {\n    \"User-Agent\": fingerprint.userAgent,\n  };\n}\n\n/**\n * Session-level fingerprint instance.\n * Generated once at module load, persists for the lifetime of the process.\n */\nlet sessionFingerprint: Fingerprint | null = null;\n\n/**\n * Get or create the session fingerprint.\n * Returns the same fingerprint for all calls within a session.\n */\nexport function getSessionFingerprint(): Fingerprint {\n  if (!sessionFingerprint) {\n    sessionFingerprint = generateFingerprint();\n  }\n  return sessionFingerprint;\n}\n\n/**\n * Regenerate the session fingerprint.\n * Call this to get a fresh identity (e.g., after rate limiting).\n */\nexport function regenerateSessionFingerprint(): Fingerprint {\n  sessionFingerprint = generateFingerprint();\n  return sessionFingerprint;\n}\n"
  },
  {
    "path": "src/plugin/image-saver.ts",
    "content": "/**\n * Image Saving Utility\n * \n * Handles saving generated images to disk and returning file paths.\n */\n\nimport * as fs from 'fs';\nimport * as path from 'path';\nimport * as os from 'os';\n\n/**\n * Default directory for saving generated images.\n * Uses ~/.opencode/generated-images/\n */\nfunction getImageOutputDir(): string {\n  const homeDir = os.homedir();\n  const outputDir = path.join(homeDir, '.opencode', 'generated-images');\n  \n  // Create directory if it doesn't exist\n  if (!fs.existsSync(outputDir)) {\n    fs.mkdirSync(outputDir, { recursive: true });\n  }\n  \n  return outputDir;\n}\n\n/**\n * Generate a unique filename for the image.\n */\nfunction generateImageFilename(mimeType: string): string {\n  const timestamp = new Date().toISOString().replace(/[:.]/g, '-');\n  const random = Math.random().toString(36).substring(2, 8);\n  \n  // Determine extension from mime type\n  let ext = 'png';\n  if (mimeType.includes('jpeg') || mimeType.includes('jpg')) {\n    ext = 'jpg';\n  } else if (mimeType.includes('gif')) {\n    ext = 'gif';\n  } else if (mimeType.includes('webp')) {\n    ext = 'webp';\n  }\n  \n  return `image-${timestamp}-${random}.${ext}`;\n}\n\n/**\n * Save base64 image data to disk and return the file path.\n * \n * @param base64Data - The base64-encoded image data\n * @param mimeType - The MIME type of the image (e.g., \"image/jpeg\")\n * @returns The absolute path to the saved image file\n */\nexport function saveImageToDisk(base64Data: string, mimeType: string): string {\n  try {\n    const outputDir = getImageOutputDir();\n    const filename = generateImageFilename(mimeType);\n    const filePath = path.join(outputDir, filename);\n    \n    // Decode base64 and write to file\n    const buffer = Buffer.from(base64Data, 'base64');\n    fs.writeFileSync(filePath, buffer);\n    \n    return filePath;\n  } catch (error) {\n    // If saving fails, return empty string (caller will fall back to base64)\n    console.error('[image-saver] Failed to save image:', error);\n    return '';\n  }\n}\n\n/**\n * Process inlineData and return either a file path or base64 data URL.\n * Attempts to save to disk first, falls back to base64 if saving fails.\n * \n * @param inlineData - Object containing mimeType and base64 data\n * @returns Markdown image string with either file path or data URL\n */\nexport function processImageData(inlineData: { mimeType?: string; data?: string }): string | null {\n  const mimeType = inlineData.mimeType || 'image/png';\n  const data = inlineData.data;\n  \n  if (!data) {\n    return null;\n  }\n  \n  // Try to save to disk first\n  const filePath = saveImageToDisk(data, mimeType);\n  \n  if (filePath) {\n    // Successfully saved - return file path with open command hint\n    return `![Generated Image](${filePath})\\n\\nImage saved to: \\`${filePath}\\`\\n\\nTo view: \\`open \"${filePath}\"\\``;\n  }\n  \n  // Fall back to base64 data URL\n  return `![Generated Image](data:${mimeType};base64,${data})`;\n}\n"
  },
  {
    "path": "src/plugin/logger.test.ts",
    "content": "import { afterEach, beforeEach, describe, expect, it, vi } from \"vitest\"\nimport { DEFAULT_CONFIG } from \"./config\"\nimport type { PluginClient } from \"./types\"\n\nconst { ensureGitignoreSyncMock } = vi.hoisted(() => ({\n  ensureGitignoreSyncMock: vi.fn(),\n}))\n\nvi.mock(\"./storage\", () => ({\n  ensureGitignoreSync: ensureGitignoreSyncMock,\n}))\n\ndescribe(\"logger sink routing\", () => {\n  beforeEach(() => {\n    vi.resetModules()\n    ensureGitignoreSyncMock.mockReset()\n  })\n\n  afterEach(async () => {\n    const { initializeDebug } = await import(\"./debug\")\n    initializeDebug(DEFAULT_CONFIG)\n  })\n\n  it(\"routes logs to TUI when debug_tui is enabled without file debug\", async () => {\n    const { initializeDebug } = await import(\"./debug\")\n    const { createLogger, initLogger } = await import(\"./logger\")\n\n    initializeDebug({\n      ...DEFAULT_CONFIG,\n      debug: false,\n      debug_tui: true,\n    })\n\n    const appLog = vi.fn().mockResolvedValue(undefined)\n    const client = {\n      app: {\n        log: appLog,\n      },\n    } as unknown as PluginClient\n\n    initLogger(client)\n\n    createLogger(\"request\").debug(\"thinking-resolution\", { status: 429 })\n\n    expect(appLog).toHaveBeenCalledTimes(1)\n    expect(appLog).toHaveBeenCalledWith({\n      body: {\n        service: \"antigravity.request\",\n        level: \"debug\",\n        message: \"thinking-resolution\",\n        extra: { status: 429 },\n      },\n    })\n  })\n\n  it(\"does not route to TUI when only file debug is enabled\", async () => {\n    const { initializeDebug } = await import(\"./debug\")\n    const { createLogger, initLogger } = await import(\"./logger\")\n\n    initializeDebug({\n      ...DEFAULT_CONFIG,\n      debug: true,\n      debug_tui: false,\n      log_dir: \"/tmp/opencode-antigravity-logger-tests\",\n    })\n\n    const appLog = vi.fn().mockResolvedValue(undefined)\n    const client = {\n      app: {\n        log: appLog,\n      },\n    } as unknown as PluginClient\n\n    initLogger(client)\n\n    createLogger(\"request\").debug(\"file-only\")\n\n    expect(appLog).not.toHaveBeenCalled()\n  })\n})\n"
  },
  {
    "path": "src/plugin/logger.ts",
    "content": "/**\n * Structured Logger for Antigravity Plugin\n *\n * Logging behavior:\n * - debug controls file logs only (via debug.ts)\n * - debug_tui controls TUI log panel only\n * - either sink can be enabled independently\n * - OPENCODE_ANTIGRAVITY_CONSOLE_LOG=1 → console output (independent of debug flags)\n */\n\nimport type { PluginClient } from \"./types\";\nimport { isDebugTuiEnabled } from \"./debug\";\nimport {\n  isTruthyFlag,\n  writeConsoleLog,\n} from \"./logging-utils\";\n\ntype LogLevel = \"debug\" | \"info\" | \"warn\" | \"error\";\n\nconst ENV_CONSOLE_LOG = \"OPENCODE_ANTIGRAVITY_CONSOLE_LOG\";\n\nexport interface Logger {\n  debug(message: string, extra?: Record<string, unknown>): void;\n  info(message: string, extra?: Record<string, unknown>): void;\n  warn(message: string, extra?: Record<string, unknown>): void;\n  error(message: string, extra?: Record<string, unknown>): void;\n}\n\nlet _client: PluginClient | null = null;\n\n/**\n * Check if console logging is enabled via environment variable.\n */\nfunction isConsoleLogEnabled(): boolean {\n  return isTruthyFlag(process.env[ENV_CONSOLE_LOG]);\n}\n\n/**\n * Initialize the logger with the plugin client.\n * Must be called during plugin initialization to enable TUI logging.\n */\nexport function initLogger(client: PluginClient): void {\n  _client = client;\n}\n\n/**\n * Create a logger instance for a specific module.\n *\n * @param module - The module name (e.g., \"refresh-queue\", \"transform.claude\")\n * @returns Logger instance with debug, info, warn, error methods\n *\n * @example\n * ```typescript\n * const log = createLogger(\"refresh-queue\");\n * log.debug(\"Checking tokens\", { count: 5 });\n * log.warn(\"Token expired\", { accountIndex: 0 });\n * ```\n */\nexport function createLogger(module: string): Logger {\n  const service = `antigravity.${module}`;\n\n  const log = (level: LogLevel, message: string, extra?: Record<string, unknown>): void => {\n    // TUI logging: controlled only by debug_tui policy\n    if (isDebugTuiEnabled()) {\n      const app = _client?.app;\n      if (app && typeof app.log === \"function\") {\n        app\n          .log({\n            body: { service, level, message, extra },\n          })\n          .catch(() => {\n            // Silently ignore logging errors\n          });\n      }\n    }\n\n    // Console fallback: when env var is set (independent of debug flags)\n    if (isConsoleLogEnabled()) {\n      const prefix = `[${service}]`;\n      const args = extra ? [prefix, message, extra] : [prefix, message];\n      writeConsoleLog(level, ...args);\n    }\n    // If neither TUI nor console logging is enabled, log is silently discarded\n  };\n\n  return {\n    debug: (message, extra) => log(\"debug\", message, extra),\n    info: (message, extra) => log(\"info\", message, extra),\n    warn: (message, extra) => log(\"warn\", message, extra),\n    error: (message, extra) => log(\"error\", message, extra),\n  };\n}\n"
  },
  {
    "path": "src/plugin/logging-utils.test.ts",
    "content": "import { describe, expect, it, vi } from \"vitest\"\nimport {\n  deriveDebugPolicy,\n  formatAccountContextLabel,\n  formatAccountLabel,\n  formatBodyPreviewForLog,\n  formatErrorForLog,\n  truncateTextForLog,\n  writeConsoleLog,\n} from \"./logging-utils\"\n\ndescribe(\"deriveDebugPolicy\", () => {\n  it(\"keeps debug_tui disabled when debug is disabled\", () => {\n    const policy = deriveDebugPolicy({\n      configDebug: false,\n      configDebugTui: true,\n      envDebugFlag: \"\",\n      envDebugTuiFlag: \"1\",\n    })\n\n    expect(policy.debugEnabled).toBe(false)\n    expect(policy.debugTuiEnabled).toBe(false)\n    expect(policy.verboseEnabled).toBe(false)\n    expect(policy.debugLevel).toBe(0)\n  })\n\n  it(\"supports verbose mode override when debug config is enabled\", () => {\n    const policy = deriveDebugPolicy({\n      configDebug: true,\n      configDebugTui: false,\n      envDebugFlag: \"verbose\",\n      envDebugTuiFlag: \"\",\n    })\n\n    expect(policy.debugEnabled).toBe(true)\n    expect(policy.debugTuiEnabled).toBe(false)\n    expect(policy.verboseEnabled).toBe(true)\n    expect(policy.debugLevel).toBe(2)\n  })\n})\n\ndescribe(\"format helpers\", () => {\n  it(\"formats account labels consistently\", () => {\n    expect(formatAccountLabel(\"person@example.com\", 4)).toBe(\"person@example.com\")\n    expect(formatAccountLabel(undefined, 1)).toBe(\"Account 2\")\n    expect(formatAccountContextLabel(undefined, -1)).toBe(\"All accounts\")\n    expect(formatAccountContextLabel(undefined, 0)).toBe(\"Account 1\")\n  })\n\n  it(\"formats errors defensively\", () => {\n    expect(formatErrorForLog(new Error(\"boom\"))).toContain(\"boom\")\n    expect(formatErrorForLog({ code: 401 })).toBe('{\"code\":401}')\n\n    const circular: { self?: unknown } = {}\n    circular.self = circular\n    expect(formatErrorForLog(circular)).toContain(\"[object Object]\")\n  })\n\n  it(\"truncates long text with metadata\", () => {\n    const longText = \"x\".repeat(12)\n    expect(truncateTextForLog(longText, 5)).toBe(\"xxxxx... (truncated 7 chars)\")\n    expect(truncateTextForLog(\"short\", 10)).toBe(\"short\")\n  })\n\n  it(\"formats body previews safely\", () => {\n    expect(formatBodyPreviewForLog(\"abcdef\", 3)).toBe(\"abc... (truncated 3 chars)\")\n    expect(formatBodyPreviewForLog(new URLSearchParams({ q: \"value\" }), 100)).toBe(\"q=value\")\n    expect(formatBodyPreviewForLog(new Uint8Array([1, 2]), 100)).toBe(\"[Uint8Array payload omitted]\")\n  })\n})\n\ndescribe(\"writeConsoleLog\", () => {\n  it(\"routes to the level-specific console method\", () => {\n    const debugSpy = vi.spyOn(console, \"debug\").mockImplementation(() => {})\n    const infoSpy = vi.spyOn(console, \"info\").mockImplementation(() => {})\n    const warnSpy = vi.spyOn(console, \"warn\").mockImplementation(() => {})\n    const errorSpy = vi.spyOn(console, \"error\").mockImplementation(() => {})\n\n    writeConsoleLog(\"debug\", \"dbg\")\n    writeConsoleLog(\"info\", \"inf\")\n    writeConsoleLog(\"warn\", \"wrn\")\n    writeConsoleLog(\"error\", \"err\")\n\n    expect(debugSpy).toHaveBeenCalledWith(\"dbg\")\n    expect(infoSpy).toHaveBeenCalledWith(\"inf\")\n    expect(warnSpy).toHaveBeenCalledWith(\"wrn\")\n    expect(errorSpy).toHaveBeenCalledWith(\"err\")\n\n    debugSpy.mockRestore()\n    infoSpy.mockRestore()\n    warnSpy.mockRestore()\n    errorSpy.mockRestore()\n  })\n})\n"
  },
  {
    "path": "src/plugin/logging-utils.ts",
    "content": "export type LogLevel = \"debug\" | \"info\" | \"warn\" | \"error\"\n\nexport interface DebugPolicyInput {\n  configDebug: boolean\n  configDebugTui: boolean\n  envDebugFlag?: string\n  envDebugTuiFlag?: string\n}\n\nexport interface DebugPolicy {\n  debugLevel: number\n  debugEnabled: boolean\n  debugTuiEnabled: boolean\n  verboseEnabled: boolean\n}\n\nexport function isTruthyFlag(flag?: string): boolean {\n  return flag === \"1\" || flag?.toLowerCase() === \"true\"\n}\n\nexport function parseDebugLevel(flag: string): number {\n  const trimmed = flag.trim()\n  if (trimmed === \"2\" || trimmed === \"verbose\") return 2\n  if (trimmed === \"1\" || trimmed === \"true\") return 1\n  return 0\n}\n\nexport function deriveDebugPolicy(input: DebugPolicyInput): DebugPolicy {\n  const envDebugFlag = input.envDebugFlag ?? \"\"\n  const debugLevel = input.configDebug\n    ? envDebugFlag === \"2\" || envDebugFlag === \"verbose\"\n      ? 2\n      : 1\n    : parseDebugLevel(envDebugFlag)\n  const debugEnabled = debugLevel >= 1\n  const verboseEnabled = debugLevel >= 2\n  const debugTuiEnabled = debugEnabled && (input.configDebugTui || isTruthyFlag(input.envDebugTuiFlag))\n\n  return {\n    debugLevel,\n    debugEnabled,\n    debugTuiEnabled,\n    verboseEnabled,\n  }\n}\n\nexport function formatAccountLabel(email: string | undefined, accountIndex: number): string {\n  return email || `Account ${accountIndex + 1}`\n}\n\nexport function formatAccountContextLabel(email: string | undefined, accountIndex: number): string {\n  if (email) {\n    return email\n  }\n  if (accountIndex >= 0) {\n    return `Account ${accountIndex + 1}`\n  }\n  return \"All accounts\"\n}\n\nexport function formatErrorForLog(error: unknown): string {\n  if (error instanceof Error) {\n    return error.stack ?? error.message\n  }\n  try {\n    return JSON.stringify(error)\n  } catch {\n    return String(error)\n  }\n}\n\nexport function truncateTextForLog(text: string, maxChars: number): string {\n  if (text.length <= maxChars) {\n    return text\n  }\n  return `${text.slice(0, maxChars)}... (truncated ${text.length - maxChars} chars)`\n}\n\nexport function formatBodyPreviewForLog(\n  body: BodyInit | null | undefined,\n  maxChars: number,\n): string | undefined {\n  if (body == null) {\n    return undefined\n  }\n\n  if (typeof body === \"string\") {\n    return truncateTextForLog(body, maxChars)\n  }\n\n  if (body instanceof URLSearchParams) {\n    return truncateTextForLog(body.toString(), maxChars)\n  }\n\n  if (typeof Blob !== \"undefined\" && body instanceof Blob) {\n    return `[Blob size=${body.size}]`\n  }\n\n  if (typeof FormData !== \"undefined\" && body instanceof FormData) {\n    return \"[FormData payload omitted]\"\n  }\n\n  return `[${body.constructor?.name ?? typeof body} payload omitted]`\n}\n\nexport function writeConsoleLog(level: LogLevel, ...args: unknown[]): void {\n  switch (level) {\n    case \"debug\":\n      console.debug(...args)\n      break\n    case \"info\":\n      console.info(...args)\n      break\n    case \"warn\":\n      console.warn(...args)\n      break\n    case \"error\":\n      console.error(...args)\n      break\n  }\n}\n"
  },
  {
    "path": "src/plugin/model-specific-quota.test.ts",
    "content": "import { describe, it, expect, beforeEach } from \"vitest\";\nimport { AccountManager } from \"./accounts\";\nimport type { OAuthAuthDetails } from \"./types\";\n\ndescribe(\"Model-specific Gemini quota\", () => {\n  let manager: AccountManager;\n  const auth: OAuthAuthDetails = {\n    type: \"oauth\",\n    refresh: \"test-refresh\",\n    access: \"test-access\",\n    expires: Date.now() + 3600000,\n  };\n\n  beforeEach(() => {\n    manager = new AccountManager(auth);\n  });\n\n  it(\"blocks only the specific Gemini model when markRateLimited is called with a model\", () => {\n    const account = manager.getCurrentAccountForFamily(\"gemini\")!;\n    const modelPro = \"gemini-1.5-pro\";\n    const modelFlash = \"gemini-1.5-flash\";\n\n    // Mark gemini-1.5-pro as rate limited on antigravity\n    manager.markRateLimited(account, 60000, \"gemini\", \"antigravity\", modelPro);\n\n    // gemini-1.5-pro should be rate limited for antigravity\n    expect(manager.isRateLimitedForHeaderStyle(account, \"gemini\", \"antigravity\", modelPro)).toBe(true);\n\n    // gemini-1.5-flash should NOT be rate limited for antigravity\n    expect(manager.isRateLimitedForHeaderStyle(account, \"gemini\", \"antigravity\", modelFlash)).toBe(false);\n\n    // General gemini (no model) should NOT be rate limited\n    expect(manager.isRateLimitedForHeaderStyle(account, \"gemini\", \"antigravity\")).toBe(false);\n  });\n\n  it(\"falls back to gemini-cli only for the specific model\", () => {\n    const account = manager.getCurrentAccountForFamily(\"gemini\")!;\n    const modelPro = \"gemini-1.5-pro\";\n    const modelFlash = \"gemini-1.5-flash\";\n\n    // Mark gemini-1.5-pro as rate limited on antigravity\n    manager.markRateLimited(account, 60000, \"gemini\", \"antigravity\", modelPro);\n\n    // Available header style for Pro should be gemini-cli\n    expect(manager.getAvailableHeaderStyle(account, \"gemini\", modelPro)).toBe(\"gemini-cli\");\n\n    // Available header style for Flash should still be antigravity\n    expect(manager.getAvailableHeaderStyle(account, \"gemini\", modelFlash)).toBe(\"antigravity\");\n  });\n\n  it(\"returns null when all header styles are exhausted for the specific model on a single account\", () => {\n    const manager2 = new AccountManager(auth);\n    \n    const account = manager2.getCurrentAccountForFamily(\"gemini\")!;\n    const modelPro = \"gemini-1.5-pro\";\n    const modelFlash = \"gemini-1.5-flash\";\n\n    manager2.markRateLimited(account, 60000, \"gemini\", \"antigravity\", modelPro);\n    manager2.markRateLimited(account, 60000, \"gemini\", \"gemini-cli\", modelPro);\n\n    // No other account available, so returns null for the rate-limited model\n    expect(manager2.getCurrentOrNextForFamily(\"gemini\", modelPro)).toBeNull();\n    \n    // Flash should still return the same account since it's not rate-limited\n    const flashAccount = manager2.getCurrentOrNextForFamily(\"gemini\", modelFlash);\n    expect(flashAccount).toBe(account);\n  });\n\n  it(\"base family rate limit blocks all models in that family\", () => {\n    const account = manager.getCurrentAccountForFamily(\"gemini\")!;\n    const modelPro = \"gemini-1.5-pro\";\n\n    // Mark base gemini-antigravity as rate limited\n    manager.markRateLimited(account, 60000, \"gemini\", \"antigravity\");\n\n    // All Gemini models should now be blocked for antigravity on this account\n    expect(manager.isRateLimitedForHeaderStyle(account, \"gemini\", \"antigravity\", modelPro)).toBe(true);\n    expect(manager.isRateLimitedForHeaderStyle(account, \"gemini\", \"antigravity\", \"gemini-1.5-flash\")).toBe(true);\n  });\n});\n"
  },
  {
    "path": "src/plugin/persist-account-pool.test.ts",
    "content": "/**\n * Tests for persistAccountPool function\n * \n * Issue #89: Multi-account login overwrites existing accounts\n * Root cause: loadAccounts() returning null is treated as \"no accounts\"\n * even when the file exists but couldn't be read (permissions, corruption, etc.)\n * \n * @see https://github.com/NoeFabris/opencode-antigravity-auth/issues/89\n */\n\nimport { describe, expect, it, vi, beforeEach, afterEach } from \"vitest\";\nimport { promises as fs } from \"node:fs\";\nimport * as storageModule from \"./storage\";\nimport type { AccountStorageV4, AccountMetadataV3 } from \"./storage\";\n\nvi.mock(\"proper-lockfile\", () => ({\n  default: {\n    lock: vi.fn().mockResolvedValue(vi.fn().mockResolvedValue(undefined)),\n  },\n}));\n\nvi.mock(\"node:fs\", async () => {\n  const actual = await vi.importActual<typeof import(\"node:fs\")>(\"node:fs\");\n  return {\n    ...actual,\n    promises: {\n      readFile: vi.fn(),\n      writeFile: vi.fn(),\n      mkdir: vi.fn().mockResolvedValue(undefined),\n      access: vi.fn().mockResolvedValue(undefined),\n      unlink: vi.fn(),\n      rename: vi.fn().mockResolvedValue(undefined),\n    },\n  };\n});\n\nfunction createMockAccount(overrides: Partial<AccountMetadataV3> = {}): AccountMetadataV3 {\n  return {\n    email: \"test@example.com\",\n    refreshToken: \"test-refresh-token\",\n    projectId: \"test-project-id\",\n    managedProjectId: \"test-managed-project-id\",\n    addedAt: Date.now() - 10000,\n    lastUsed: Date.now(),\n    ...overrides,\n  };\n}\n\nfunction createMockStorage(accounts: AccountMetadataV3[], activeIndex = 0): AccountStorageV4 {\n  return {\n    version: 4,\n    accounts,\n    activeIndex,\n  };\n}\n\ndescribe(\"loadAccounts\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  afterEach(() => {\n    vi.restoreAllMocks();\n  });\n\n  describe(\"file not found (ENOENT)\", () => {\n    it(\"returns null when file does not exist\", async () => {\n      const error = new Error(\"ENOENT\") as NodeJS.ErrnoException;\n      error.code = \"ENOENT\";\n      vi.mocked(fs.readFile).mockRejectedValue(error);\n\n      const result = await storageModule.loadAccounts();\n\n      expect(result).toBeNull();\n    });\n  });\n\n  describe(\"file exists with valid data\", () => {\n    it(\"returns storage for valid V3 file\", async () => {\n      const mockStorage = createMockStorage([createMockAccount()]);\n      vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify(mockStorage));\n\n      const result = await storageModule.loadAccounts();\n\n      expect(result).not.toBeNull();\n      expect(result?.version).toBe(4);\n      expect(result?.accounts).toHaveLength(1);\n    });\n\n    it(\"returns storage with multiple accounts\", async () => {\n      const mockStorage = createMockStorage([\n        createMockAccount({ email: \"user1@example.com\", refreshToken: \"token1\" }),\n        createMockAccount({ email: \"user2@example.com\", refreshToken: \"token2\" }),\n      ]);\n      vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify(mockStorage));\n\n      const result = await storageModule.loadAccounts();\n\n      expect(result?.accounts).toHaveLength(2);\n      expect(result?.accounts[0]?.email).toBe(\"user1@example.com\");\n      expect(result?.accounts[1]?.email).toBe(\"user2@example.com\");\n    });\n\n    it(\"preserves activeIndex from storage\", async () => {\n      const mockStorage = createMockStorage([\n        createMockAccount({ email: \"user1@example.com\" }),\n        createMockAccount({ email: \"user2@example.com\" }),\n      ], 1);\n      vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify(mockStorage));\n\n      const result = await storageModule.loadAccounts();\n\n      expect(result?.activeIndex).toBe(1);\n    });\n  });\n\n  describe(\"error handling - THE BUG (Issue #89)\", () => {\n    /**\n     * THIS IS THE BUG: loadAccounts returns null for ANY error, not just ENOENT.\n     * The caller (persistAccountPool) cannot distinguish between:\n     * - File doesn't exist (safe to create new)\n     * - File exists but couldn't be read (DANGEROUS - would overwrite!)\n     */\n\n    it(\"returns null on permission denied (EACCES)\", async () => {\n      const error = new Error(\"EACCES\") as NodeJS.ErrnoException;\n      error.code = \"EACCES\";\n      vi.mocked(fs.readFile).mockRejectedValue(error);\n\n      const result = await storageModule.loadAccounts();\n\n      expect(result).toBeNull();\n    });\n\n    it(\"returns null on JSON parse error\", async () => {\n      vi.mocked(fs.readFile).mockResolvedValue(\"{ invalid json }}}\");\n\n      const result = await storageModule.loadAccounts();\n\n      expect(result).toBeNull();\n    });\n\n    it(\"returns null on invalid storage format\", async () => {\n      vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify({ version: 4, notAccounts: [] }));\n\n      const result = await storageModule.loadAccounts();\n\n      expect(result).toBeNull();\n    });\n\n    it(\"returns null on unknown version\", async () => {\n      vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify({ version: 999, accounts: [] }));\n\n      const result = await storageModule.loadAccounts();\n\n      expect(result).toBeNull();\n    });\n  });\n\n  describe(\"migration\", () => {\n    it(\"migrates V2 to V3 successfully\", async () => {\n      const v2Storage = {\n        version: 2,\n        accounts: [\n          {\n            refreshToken: \"token1\",\n            addedAt: Date.now() - 10000,\n            lastUsed: Date.now(),\n          },\n        ],\n        activeIndex: 0,\n      };\n      vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify(v2Storage));\n      vi.mocked(fs.writeFile).mockResolvedValue(undefined);\n\n      const result = await storageModule.loadAccounts();\n\n      expect(result?.version).toBe(4);\n      expect(result?.accounts).toHaveLength(1);\n    });\n  });\n});\n\ndescribe(\"saveAccounts\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  it(\"saves valid storage to disk\", async () => {\n    vi.mocked(fs.writeFile).mockResolvedValue(undefined);\n    vi.mocked(fs.mkdir).mockResolvedValue(undefined);\n\n    const storage = createMockStorage([createMockAccount()]);\n    await storageModule.saveAccounts(storage);\n\n    expect(fs.writeFile).toHaveBeenCalledTimes(1);\n    const writtenContent = vi.mocked(fs.writeFile).mock.calls[0]?.[1];\n    expect(writtenContent).toBeDefined();\n    const parsed = JSON.parse(writtenContent as string);\n    expect(parsed.version).toBe(4);\n    expect(parsed.accounts).toHaveLength(1);\n  });\n});\n\n/**\n * Tests for the expected behavior of persistAccountPool\n * \n * NOTE: persistAccountPool is currently a private function in plugin.ts.\n * These tests document the EXPECTED behavior after the fix.\n * To run these tests, persistAccountPool should be exported.\n */\ndescribe(\"persistAccountPool behavior (Issue #89)\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n    vi.useFakeTimers();\n    vi.setSystemTime(new Date(\"2026-01-01T12:00:00Z\"));\n  });\n\n  afterEach(() => {\n    vi.useRealTimers();\n  });\n\n  describe(\"merging behavior (replaceAll=false)\", () => {\n    it.todo(\"merges new account with existing accounts\");\n    \n    it.todo(\"deduplicates by email, keeping the newest token\");\n    \n    it.todo(\"deduplicates by refresh token when email not available\");\n    \n    it.todo(\"preserves activeIndex when adding new accounts\");\n    \n    it.todo(\"updates lastUsed timestamp for existing accounts\");\n  });\n\n  describe(\"fresh start behavior (replaceAll=true)\", () => {\n    it.todo(\"replaces all existing accounts with new ones\");\n    \n    it.todo(\"resets activeIndex to 0\");\n    \n    it.todo(\"ignores existing accounts file\");\n  });\n\n  describe(\"THE BUG: error handling when loadAccounts fails (Issue #89)\", () => {\n    /**\n     * Current buggy behavior:\n     * 1. User has accounts saved in ~/.config/opencode/antigravity-accounts.json\n     * 2. loadAccounts() fails (permission error, JSON parse error, etc.)\n     * 3. loadAccounts() returns null\n     * 4. persistAccountPool treats null as \"no accounts exist\"\n     * 5. New account REPLACES existing accounts instead of merging\n     * \n     * Expected behavior after fix:\n     * 1. loadAccounts() should distinguish ENOENT from other errors\n     * 2. persistAccountPool should throw/warn when file exists but can't be read\n     * 3. User should be prompted about potential data loss\n     */\n\n    it.todo(\"should NOT overwrite accounts when loadAccounts returns null due to permission error\");\n    \n    it.todo(\"should throw error when file exists but cannot be read\");\n    \n    it.todo(\"should prompt user when existing accounts may be lost\");\n    \n    it.todo(\"should only treat ENOENT as 'safe to create new file'\");\n  });\n});\n\n/**\n * Tests for TUI flow integration (Issue #89)\n * \n * The user's logs showed they went through TUI flow, not CLI flow.\n * TUI flow calls persistAccountPool with replaceAll=false,\n * which should merge accounts but doesn't when loadAccounts fails.\n */\ndescribe(\"TUI flow integration (Issue #89)\", () => {\n  describe(\"account persistence after OAuth\", () => {\n    it.todo(\"should merge new account with existing accounts in TUI flow\");\n    \n    it.todo(\"should show warning when existing accounts cannot be loaded\");\n    \n    it.todo(\"should ask user for confirmation before potentially overwriting accounts\");\n  });\n\n  describe(\"authorize function behavior\", () => {\n    it.todo(\"TUI flow (inputs falsy) should check for existing accounts\");\n    \n    it.todo(\"should handle loadAccounts returning null gracefully\");\n  });\n});\n\n/**\n * Regression tests to ensure the fix doesn't break normal operation\n */\ndescribe(\"regression tests\", () => {\n  beforeEach(() => {\n    vi.clearAllMocks();\n  });\n\n  describe(\"first-time user experience\", () => {\n    it(\"should work correctly when no accounts file exists (ENOENT)\", async () => {\n      const error = new Error(\"ENOENT\") as NodeJS.ErrnoException;\n      error.code = \"ENOENT\";\n      vi.mocked(fs.readFile).mockRejectedValue(error);\n\n      const result = await storageModule.loadAccounts();\n      expect(result).toBeNull();\n\n      vi.mocked(fs.writeFile).mockResolvedValue(undefined);\n      vi.mocked(fs.mkdir).mockResolvedValue(undefined);\n\n      const newStorage = createMockStorage([createMockAccount()]);\n      await expect(storageModule.saveAccounts(newStorage)).resolves.not.toThrow();\n    });\n  });\n\n  describe(\"normal multi-account workflow\", () => {\n    it(\"should load existing accounts correctly\", async () => {\n      const existingStorage = createMockStorage([\n        createMockAccount({ email: \"existing@example.com\" }),\n      ]);\n      vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify(existingStorage));\n\n      const result = await storageModule.loadAccounts();\n\n      expect(result).not.toBeNull();\n      expect(result?.accounts).toHaveLength(1);\n      expect(result?.accounts[0]?.email).toBe(\"existing@example.com\");\n    });\n\n  it(\"should preserve all accounts when saving\", async () => {\n    const enoent = new Error(\"ENOENT\") as NodeJS.ErrnoException;\n    enoent.code = \"ENOENT\";\n    vi.mocked(fs.readFile).mockRejectedValue(enoent);\n    vi.mocked(fs.writeFile).mockResolvedValue(undefined);\n    vi.mocked(fs.mkdir).mockResolvedValue(undefined);\n\n    const storage = createMockStorage([\n        createMockAccount({ email: \"user1@example.com\", refreshToken: \"token1\" }),\n        createMockAccount({ email: \"user2@example.com\", refreshToken: \"token2\" }),\n        createMockAccount({ email: \"user3@example.com\", refreshToken: \"token3\" }),\n      ]);\n\n      await storageModule.saveAccounts(storage);\n\n      expect(fs.writeFile).toHaveBeenCalledTimes(2);\n\n      const tmpWriteCall = vi.mocked(fs.writeFile).mock.calls.find(\n        (call) => (call[0] as string).includes(\".tmp\")\n      );\n      expect(tmpWriteCall).toBeDefined();\n      const parsed = JSON.parse(tmpWriteCall![1] as string);\n      expect(parsed.accounts).toHaveLength(3);\n\n      const gitignoreWriteCall = vi.mocked(fs.writeFile).mock.calls.find(\n        (call) => (call[0] as string).includes(\".gitignore\")\n      );\n      expect(gitignoreWriteCall).toBeDefined();\n    });\n  });\n});\n\n/**\n * Proposed fix validation tests\n * \n * These tests validate enhanced error handling behavior.\n */\ndescribe(\"proposed fix validation\", () => {\n  describe(\"loadAccounts should distinguish error types\", () => {\n    it.todo(\"should return { error: 'ENOENT' } when file doesn't exist\");\n    it.todo(\"should return { error: 'PERMISSION_DENIED' } on EACCES\");\n    it.todo(\"should return { error: 'PARSE_ERROR' } on invalid JSON\");\n    it.todo(\"should return { error: 'INVALID_FORMAT' } on schema mismatch\");\n  });\n\n  describe(\"persistAccountPool should handle errors safely\", () => {\n    it.todo(\"should throw AccountFileUnreadableError when file exists but can't be read\");\n    it.todo(\"should include recovery instructions in error message\");\n  });\n\n  describe(\"user prompts for data safety\", () => {\n    it.todo(\"should prompt user when accounts file exists but is unreadable\");\n    it.todo(\"should offer options: (r)etry, (b)ackup and continue, (a)bort\");\n  });\n});\n"
  },
  {
    "path": "src/plugin/project.ts",
    "content": "import {\n  getAntigravityHeaders,\n  ANTIGRAVITY_ENDPOINT_FALLBACKS,\n  ANTIGRAVITY_LOAD_ENDPOINTS,\n  ANTIGRAVITY_DEFAULT_PROJECT_ID,\n} from \"../constants\";\nimport { formatRefreshParts, parseRefreshParts } from \"./auth\";\nimport { createLogger } from \"./logger\";\nimport type { OAuthAuthDetails, ProjectContextResult } from \"./types\";\n\nconst log = createLogger(\"project\");\n\nconst projectContextResultCache = new Map<string, ProjectContextResult>();\nconst projectContextPendingCache = new Map<string, Promise<ProjectContextResult>>();\n\nconst CODE_ASSIST_METADATA = {\n  ideType: \"ANTIGRAVITY\",\n  platform: process.platform === \"win32\" ? \"WINDOWS\" : \"MACOS\",\n  pluginType: \"GEMINI\",\n} as const;\n\ninterface AntigravityUserTier {\n  id?: string;\n  isDefault?: boolean;\n  userDefinedCloudaicompanionProject?: boolean;\n}\n\ninterface LoadCodeAssistPayload {\n  cloudaicompanionProject?: string | { id?: string };\n  currentTier?: {\n    id?: string;\n  };\n  allowedTiers?: AntigravityUserTier[];\n}\n\ninterface OnboardUserPayload {\n  done?: boolean;\n  response?: {\n    cloudaicompanionProject?: {\n      id?: string;\n    };\n  };\n}\n\nfunction buildMetadata(projectId?: string): Record<string, string> {\n  const metadata: Record<string, string> = {\n    ideType: CODE_ASSIST_METADATA.ideType,\n    platform: CODE_ASSIST_METADATA.platform,\n    pluginType: CODE_ASSIST_METADATA.pluginType,\n  };\n  if (projectId) {\n    metadata.duetProject = projectId;\n  }\n  return metadata;\n}\n\n/**\n * Selects the default tier ID from the allowed tiers list.\n */\nfunction getDefaultTierId(allowedTiers?: AntigravityUserTier[]): string | undefined {\n  if (!allowedTiers || allowedTiers.length === 0) {\n    return undefined;\n  }\n  for (const tier of allowedTiers) {\n    if (tier?.isDefault) {\n      return tier.id;\n    }\n  }\n  return allowedTiers[0]?.id;\n}\n\n/**\n * Promise-based delay utility.\n */\nfunction wait(ms: number): Promise<void> {\n  return new Promise(function (resolve) {\n    setTimeout(resolve, ms);\n  });\n}\n\n/**\n * Extracts the cloudaicompanion project id from loadCodeAssist responses.\n */\nfunction extractManagedProjectId(payload: LoadCodeAssistPayload | null): string | undefined {\n  if (!payload) {\n    return undefined;\n  }\n  if (typeof payload.cloudaicompanionProject === \"string\") {\n    return payload.cloudaicompanionProject;\n  }\n  if (payload.cloudaicompanionProject && typeof payload.cloudaicompanionProject.id === \"string\") {\n    return payload.cloudaicompanionProject.id;\n  }\n  return undefined;\n}\n\n/**\n * Generates a cache key for project context based on refresh token.\n */\nfunction getCacheKey(auth: OAuthAuthDetails): string | undefined {\n  const refresh = auth.refresh?.trim();\n  return refresh ? refresh : undefined;\n}\n\n/**\n * Clears cached project context results and pending promises, globally or for a refresh key.\n */\nexport function invalidateProjectContextCache(refresh?: string): void {\n  if (!refresh) {\n    projectContextPendingCache.clear();\n    projectContextResultCache.clear();\n    return;\n  }\n  projectContextPendingCache.delete(refresh);\n  projectContextResultCache.delete(refresh);\n}\n\n/**\n * Loads managed project information for the given access token and optional project.\n */\nexport async function loadManagedProject(\n  accessToken: string,\n  projectId?: string,\n): Promise<LoadCodeAssistPayload | null> {\n  const metadata = buildMetadata(projectId);\n  const requestBody: Record<string, unknown> = { metadata };\n\n  const loadHeaders: Record<string, string> = {\n    \"Content-Type\": \"application/json\",\n    Authorization: `Bearer ${accessToken}`,\n    \"User-Agent\": \"google-api-nodejs-client/9.15.1\",\n    \"X-Goog-Api-Client\": \"google-cloud-sdk vscode_cloudshelleditor/0.1\",\n    \"Client-Metadata\": getAntigravityHeaders()[\"Client-Metadata\"],\n  };\n\n  const loadEndpoints = Array.from(\n    new Set<string>([...ANTIGRAVITY_LOAD_ENDPOINTS, ...ANTIGRAVITY_ENDPOINT_FALLBACKS]),\n  );\n\n  for (const baseEndpoint of loadEndpoints) {\n    try {\n      const response = await fetch(\n        `${baseEndpoint}/v1internal:loadCodeAssist`,\n        {\n          method: \"POST\",\n          headers: loadHeaders,\n          body: JSON.stringify(requestBody),\n        },\n      );\n\n      if (!response.ok) {\n        continue;\n      }\n\n      return (await response.json()) as LoadCodeAssistPayload;\n    } catch (error) {\n      log.debug(\"Failed to load managed project\", { endpoint: baseEndpoint, error: String(error) });\n      continue;\n    }\n  }\n\n  return null;\n}\n\n\n/**\n * Onboards a managed project for the user, optionally retrying until completion.\n */\nexport async function onboardManagedProject(\n  accessToken: string,\n  tierId: string,\n  projectId?: string,\n  attempts = 10,\n  delayMs = 5000,\n): Promise<string | undefined> {\n  const metadata = buildMetadata(projectId);\n  const requestBody: Record<string, unknown> = {\n    tierId,\n    metadata,\n  };\n\n  for (const baseEndpoint of ANTIGRAVITY_ENDPOINT_FALLBACKS) {\n    for (let attempt = 0; attempt < attempts; attempt += 1) {\n      try {\n        const response = await fetch(\n          `${baseEndpoint}/v1internal:onboardUser`,\n          {\n            method: \"POST\",\n            headers: {\n              \"Content-Type\": \"application/json\",\n              Authorization: `Bearer ${accessToken}`,\n              ...getAntigravityHeaders(),\n            },\n            body: JSON.stringify(requestBody),\n          },\n        );\n\n        if (!response.ok) {\n          break;\n        }\n\n        const payload = (await response.json()) as OnboardUserPayload;\n        const managedProjectId = payload.response?.cloudaicompanionProject?.id;\n        if (payload.done && managedProjectId) {\n          return managedProjectId;\n        }\n        if (payload.done && projectId) {\n          return projectId;\n        }\n      } catch (error) {\n        log.debug(\"Failed to onboard managed project\", { endpoint: baseEndpoint, error: String(error) });\n        break;\n      }\n\n      await wait(delayMs);\n    }\n  }\n\n  return undefined;\n}\n\n/**\n * Resolves an effective project ID for the current auth state, caching results per refresh token.\n */\nexport async function ensureProjectContext(auth: OAuthAuthDetails): Promise<ProjectContextResult> {\n  const accessToken = auth.access;\n  if (!accessToken) {\n    return { auth, effectiveProjectId: \"\" };\n  }\n\n  const cacheKey = getCacheKey(auth);\n  if (cacheKey) {\n    const cached = projectContextResultCache.get(cacheKey);\n    if (cached) {\n      return cached;\n    }\n    const pending = projectContextPendingCache.get(cacheKey);\n    if (pending) {\n      return pending;\n    }\n  }\n\n  const resolveContext = async (): Promise<ProjectContextResult> => {\n    const parts = parseRefreshParts(auth.refresh);\n    if (parts.managedProjectId) {\n      return { auth, effectiveProjectId: parts.managedProjectId };\n    }\n\n    const fallbackProjectId = ANTIGRAVITY_DEFAULT_PROJECT_ID;\n    const persistManagedProject = async (managedProjectId: string): Promise<ProjectContextResult> => {\n      const updatedAuth: OAuthAuthDetails = {\n        ...auth,\n        refresh: formatRefreshParts({\n          refreshToken: parts.refreshToken,\n          projectId: parts.projectId,\n          managedProjectId,\n        }),\n      };\n\n      return { auth: updatedAuth, effectiveProjectId: managedProjectId };\n    };\n\n    // Try to resolve a managed project from Antigravity if possible.\n    const loadPayload = await loadManagedProject(accessToken, parts.projectId ?? fallbackProjectId);\n    const resolvedManagedProjectId = extractManagedProjectId(loadPayload);\n\n    if (resolvedManagedProjectId) {\n      return persistManagedProject(resolvedManagedProjectId);\n    }\n\n    // No managed project found - try to auto-provision one via onboarding.\n    // This handles accounts that were added before managed project provisioning was required.\n    const tierId = getDefaultTierId(loadPayload?.allowedTiers) ?? \"FREE\";\n    log.debug(\"Auto-provisioning managed project\", { tierId, projectId: parts.projectId });\n    \n    const provisionedProjectId = await onboardManagedProject(\n      accessToken,\n      tierId,\n      parts.projectId,\n    );\n\n    if (provisionedProjectId) {\n      log.debug(\"Successfully provisioned managed project\", { provisionedProjectId });\n      return persistManagedProject(provisionedProjectId);\n    }\n\n    log.warn(\"Failed to provision managed project - account may not work correctly\", {\n      hasProjectId: !!parts.projectId,\n    });\n\n    if (parts.projectId) {\n      return { auth, effectiveProjectId: parts.projectId };\n    }\n\n    // No project id present in auth; fall back to the hardcoded id for requests.\n    return { auth, effectiveProjectId: fallbackProjectId };\n  };\n\n  if (!cacheKey) {\n    return resolveContext();\n  }\n\n  const promise = resolveContext()\n    .then((result) => {\n      const nextKey = getCacheKey(result.auth) ?? cacheKey;\n      projectContextPendingCache.delete(cacheKey);\n      projectContextResultCache.set(nextKey, result);\n      if (nextKey !== cacheKey) {\n        projectContextResultCache.delete(cacheKey);\n      }\n      return result;\n    })\n    .catch((error) => {\n      projectContextPendingCache.delete(cacheKey);\n      throw error;\n    });\n\n  projectContextPendingCache.set(cacheKey, promise);\n  return promise;\n}\n"
  },
  {
    "path": "src/plugin/quota-fallback.test.ts",
    "content": "import { beforeAll, describe, expect, it, vi } from \"vitest\";\nimport type { HeaderStyle, ModelFamily } from \"./accounts\";\n\ntype ResolveQuotaFallbackHeaderStyle = (input: {\n  family: ModelFamily;\n  headerStyle: HeaderStyle;\n  alternateStyle: HeaderStyle | null;\n}) => HeaderStyle | null;\n\ntype GetHeaderStyleFromUrl = (\n  urlString: string,\n  family: ModelFamily,\n  cliFirst?: boolean,\n) => HeaderStyle;\n\ntype ResolveHeaderRoutingDecision = (\n  urlString: string,\n  family: ModelFamily,\n  config: unknown,\n) => {\n  cliFirst: boolean;\n  preferredHeaderStyle: HeaderStyle;\n  explicitQuota: boolean;\n  allowQuotaFallback: boolean;\n};\n\nlet resolveQuotaFallbackHeaderStyle: ResolveQuotaFallbackHeaderStyle | undefined;\nlet getHeaderStyleFromUrl: GetHeaderStyleFromUrl | undefined;\nlet resolveHeaderRoutingDecision: ResolveHeaderRoutingDecision | undefined;\n\nbeforeAll(async () => {\n  vi.mock(\"@opencode-ai/plugin\", () => ({\n    tool: vi.fn(),\n  }));\n\n  const { __testExports } = await import(\"../plugin\");\n  resolveQuotaFallbackHeaderStyle = (__testExports as {\n    resolveQuotaFallbackHeaderStyle?: ResolveQuotaFallbackHeaderStyle;\n  }).resolveQuotaFallbackHeaderStyle;\n  getHeaderStyleFromUrl = (__testExports as {\n    getHeaderStyleFromUrl?: GetHeaderStyleFromUrl;\n  }).getHeaderStyleFromUrl;\n  resolveHeaderRoutingDecision = (__testExports as {\n    resolveHeaderRoutingDecision?: ResolveHeaderRoutingDecision;\n  }).resolveHeaderRoutingDecision;\n});\n\ndescribe(\"quota fallback direction\", () => {\n  it(\"falls back from gemini-cli to antigravity when alternate quota is available\", () => {\n    const result = resolveQuotaFallbackHeaderStyle?.({\n      family: \"gemini\",\n      headerStyle: \"gemini-cli\",\n      alternateStyle: \"antigravity\",\n    });\n\n    expect(result).toBe(\"antigravity\");\n  });\n\n  it(\"falls back from antigravity to gemini-cli when alternate quota is available\", () => {\n    const result = resolveQuotaFallbackHeaderStyle?.({\n      family: \"gemini\",\n      headerStyle: \"antigravity\",\n      alternateStyle: \"gemini-cli\",\n    });\n\n    expect(result).toBe(\"gemini-cli\");\n  });\n\n  it(\"returns null when no alternate quota is available\", () => {\n    const result = resolveQuotaFallbackHeaderStyle?.({\n      family: \"gemini\",\n      headerStyle: \"antigravity\",\n      alternateStyle: null,\n    });\n\n    expect(result).toBeNull();\n  });\n});\n\ndescribe(\"header style resolution\", () => {\n  it(\"uses gemini-cli for unsuffixed Gemini models when cli_first is enabled\", () => {\n    const headerStyle = getHeaderStyleFromUrl?.(\n      \"https://generativelanguage.googleapis.com/v1beta/models/gemini-3-flash:streamGenerateContent\",\n      \"gemini\",\n      true,\n    );\n\n    expect(headerStyle).toBe(\"gemini-cli\");\n  });\n\n  it(\"keeps antigravity for unsuffixed Gemini models when cli_first is disabled\", () => {\n    const headerStyle = getHeaderStyleFromUrl?.(\n      \"https://generativelanguage.googleapis.com/v1beta/models/gemini-3-flash:streamGenerateContent\",\n      \"gemini\",\n      false,\n    );\n\n    expect(headerStyle).toBe(\"antigravity\");\n  });\n\n  it(\"keeps antigravity for explicit antigravity prefix when cli_first is enabled\", () => {\n    const headerStyle = getHeaderStyleFromUrl?.(\n      \"https://generativelanguage.googleapis.com/v1beta/models/antigravity-gemini-3-flash:streamGenerateContent\",\n      \"gemini\",\n      true,\n    );\n\n    expect(headerStyle).toBe(\"antigravity\");\n  });\n\n  it(\"keeps antigravity for Claude when cli_first is enabled\", () => {\n    const headerStyle = getHeaderStyleFromUrl?.(\n      \"https://generativelanguage.googleapis.com/v1beta/models/claude-opus-4-6-thinking:streamGenerateContent\",\n      \"claude\",\n      true,\n    );\n\n    expect(headerStyle).toBe(\"antigravity\");\n  });\n});\n\ndescribe(\"header routing decision\", () => {\n  it(\"defaults to antigravity-first for unsuffixed Gemini when cli_first is disabled\", () => {\n    const decision = resolveHeaderRoutingDecision?.(\n      \"https://generativelanguage.googleapis.com/v1beta/models/gemini-3-flash:streamGenerateContent\",\n      \"gemini\",\n      {\n        cli_first: false,\n      },\n    );\n\n    expect(decision).toMatchObject({\n      cliFirst: false,\n      preferredHeaderStyle: \"antigravity\",\n      explicitQuota: false,\n      allowQuotaFallback: true,\n    });\n  });\n\n  it(\"uses gemini-cli-first for unsuffixed Gemini when cli_first is enabled\", () => {\n    const decision = resolveHeaderRoutingDecision?.(\n      \"https://generativelanguage.googleapis.com/v1beta/models/gemini-3-flash:streamGenerateContent\",\n      \"gemini\",\n      {\n        cli_first: true,\n      },\n    );\n\n    expect(decision).toMatchObject({\n      cliFirst: true,\n      preferredHeaderStyle: \"gemini-cli\",\n      explicitQuota: false,\n      allowQuotaFallback: true,\n    });\n  });\n\n  it(\"keeps explicit antigravity prefix as primary route while fallback remains available\", () => {\n    const decision = resolveHeaderRoutingDecision?.(\n      \"https://generativelanguage.googleapis.com/v1beta/models/antigravity-gemini-3-flash:streamGenerateContent\",\n      \"gemini\",\n      {\n        cli_first: true,\n      },\n    );\n\n    expect(decision).toMatchObject({\n      cliFirst: true,\n      preferredHeaderStyle: \"antigravity\",\n      explicitQuota: true,\n      allowQuotaFallback: true,\n    });\n  });\n\n  it(\"ignores legacy quota_fallback when deciding Gemini fallback availability\", () => {\n    const decision = resolveHeaderRoutingDecision?.(\n      \"https://generativelanguage.googleapis.com/v1beta/models/gemini-3-flash:streamGenerateContent\",\n      \"gemini\",\n      {\n        cli_first: false,\n        quota_fallback: false,\n      },\n    );\n\n    expect(decision).toMatchObject({\n      cliFirst: false,\n      preferredHeaderStyle: \"antigravity\",\n      explicitQuota: false,\n      allowQuotaFallback: true,\n    });\n  });\n});\n"
  },
  {
    "path": "src/plugin/quota.ts",
    "content": "import {\n  ANTIGRAVITY_ENDPOINT_PROD,\n  getAntigravityHeaders,\n  ANTIGRAVITY_PROVIDER_ID,\n} from \"../constants\";\nimport { accessTokenExpired, formatRefreshParts, parseRefreshParts } from \"./auth\";\nimport { logQuotaFetch, logQuotaStatus } from \"./debug\";\nimport { ensureProjectContext } from \"./project\";\nimport { refreshAccessToken } from \"./token\";\nimport { getModelFamily } from \"./transform/model-resolver\";\nimport type { PluginClient, OAuthAuthDetails } from \"./types\";\nimport type { AccountMetadataV3 } from \"./storage\";\n\nconst FETCH_TIMEOUT_MS = 10000;\n\nexport type QuotaGroup = \"claude\" | \"gemini-pro\" | \"gemini-flash\";\n\nexport interface QuotaGroupSummary {\n  remainingFraction?: number;\n  resetTime?: string;\n  modelCount: number;\n}\n\nexport interface QuotaSummary {\n  groups: Partial<Record<QuotaGroup, QuotaGroupSummary>>;\n  modelCount: number;\n  error?: string;\n}\n\n// Gemini CLI quota types\nexport interface GeminiCliQuotaModel {\n  modelId: string;\n  remainingFraction: number;\n  resetTime?: string;\n}\n\nexport interface GeminiCliQuotaSummary {\n  models: GeminiCliQuotaModel[];\n  error?: string;\n}\n\ninterface RetrieveUserQuotaResponse {\n  buckets?: {\n    remainingAmount?: string;\n    remainingFraction?: number;\n    resetTime?: string;\n    tokenType?: string;\n    modelId?: string;\n  }[];\n}\n\nexport type AccountQuotaStatus = \"ok\" | \"disabled\" | \"error\";\n\nexport interface AccountQuotaResult {\n  index: number;\n  email?: string;\n  status: AccountQuotaStatus;\n  error?: string;\n  disabled?: boolean;\n  quota?: QuotaSummary;\n  geminiCliQuota?: GeminiCliQuotaSummary;\n  updatedAccount?: AccountMetadataV3;\n}\n\ninterface FetchAvailableModelsResponse {\n  models?: Record<string, FetchAvailableModelEntry>;\n}\n\ninterface FetchAvailableModelEntry {\n  quotaInfo?: {\n    remainingFraction?: number;\n    resetTime?: string;\n  };\n  displayName?: string;\n  modelName?: string;\n}\n\nfunction buildAuthFromAccount(account: AccountMetadataV3): OAuthAuthDetails {\n  return {\n    type: \"oauth\",\n    refresh: formatRefreshParts({\n      refreshToken: account.refreshToken,\n      projectId: account.projectId,\n      managedProjectId: account.managedProjectId,\n    }),\n    access: undefined,\n    expires: undefined,\n  };\n}\n\nfunction normalizeRemainingFraction(value: unknown): number {\n  // If value is missing or invalid, treat as exhausted (0%)\n  if (typeof value !== \"number\" || !Number.isFinite(value)) {\n    return 0;\n  }\n  if (value < 0) return 0;\n  if (value > 1) return 1;\n  return value;\n}\n\nfunction parseResetTime(resetTime?: string): number | null {\n  if (!resetTime) return null;\n  const timestamp = Date.parse(resetTime);\n  if (!Number.isFinite(timestamp)) {\n    return null;\n  }\n  return timestamp;\n}\n\nfunction classifyQuotaGroup(modelName: string, displayName?: string): QuotaGroup | null {\n  const combined = `${modelName} ${displayName ?? \"\"}`.toLowerCase();\n  if (combined.includes(\"claude\")) {\n    return \"claude\";\n  }\n  const isGemini3 = combined.includes(\"gemini-3\") || combined.includes(\"gemini 3\");\n  if (!isGemini3) {\n    return null;\n  }\n  const family = getModelFamily(modelName);\n  return family === \"gemini-flash\" ? \"gemini-flash\" : \"gemini-pro\";\n}\n\nfunction aggregateQuota(models?: Record<string, FetchAvailableModelEntry>): QuotaSummary {\n  const groups: Partial<Record<QuotaGroup, QuotaGroupSummary>> = {};\n  if (!models) {\n    return { groups, modelCount: 0 };\n  }\n\n  let totalCount = 0;\n  for (const [modelName, entry] of Object.entries(models)) {\n    const group = classifyQuotaGroup(modelName, entry.displayName ?? entry.modelName);\n    if (!group) {\n      continue;\n    }\n    const quotaInfo = entry.quotaInfo;\n    const remainingFraction = quotaInfo\n      ? normalizeRemainingFraction(quotaInfo.remainingFraction)\n      : undefined;\n    const resetTime = quotaInfo?.resetTime;\n    const resetTimestamp = parseResetTime(resetTime);\n\n    totalCount += 1;\n\n    const existing = groups[group];\n    const nextCount = (existing?.modelCount ?? 0) + 1;\n    const nextRemaining =\n      remainingFraction === undefined\n        ? existing?.remainingFraction\n        : existing?.remainingFraction === undefined\n          ? remainingFraction\n          : Math.min(existing.remainingFraction, remainingFraction);\n\n    let nextResetTime = existing?.resetTime;\n    if (resetTimestamp !== null) {\n      if (!existing?.resetTime) {\n        nextResetTime = resetTime;\n      } else {\n        const existingTimestamp = parseResetTime(existing.resetTime);\n        if (existingTimestamp === null || resetTimestamp < existingTimestamp) {\n          nextResetTime = resetTime;\n        }\n      }\n    }\n\n    groups[group] = {\n      remainingFraction: nextRemaining,\n      resetTime: nextResetTime,\n      modelCount: nextCount,\n    };\n  }\n\n  return { groups, modelCount: totalCount };\n}\n\nasync function fetchWithTimeout(url: string, options: RequestInit, timeoutMs = FETCH_TIMEOUT_MS): Promise<Response> {\n  const controller = new AbortController();\n  const timeout = setTimeout(() => controller.abort(), timeoutMs);\n  try {\n    return await fetch(url, { ...options, signal: controller.signal });\n  } finally {\n    clearTimeout(timeout);\n  }\n}\n\nasync function fetchAvailableModels(\n  accessToken: string,\n  projectId: string,\n): Promise<FetchAvailableModelsResponse> {\n  const endpoint = ANTIGRAVITY_ENDPOINT_PROD;\n  const quotaUserAgent = getAntigravityHeaders()[\"User-Agent\"] || \"antigravity/windows/amd64\";\n  const errors: string[] = [];\n\n  const body = projectId ? { project: projectId } : {};\n  const response = await fetchWithTimeout(`${endpoint}/v1internal:fetchAvailableModels`, {\n    method: \"POST\",\n    headers: {\n      Authorization: `Bearer ${accessToken}`,\n      \"Content-Type\": \"application/json\",\n      \"User-Agent\": quotaUserAgent,\n    },\n    body: JSON.stringify(body),\n  });\n\n  if (response.ok) {\n    return (await response.json()) as FetchAvailableModelsResponse;\n  }\n\n  const message = await response.text().catch(() => \"\");\n  const snippet = message.trim().slice(0, 200);\n  errors.push(\n    `fetchAvailableModels ${response.status} at ${endpoint}${snippet ? `: ${snippet}` : \"\"}`,\n  );\n\n  throw new Error(errors.join(\"; \") || \"fetchAvailableModels failed\");\n}\n\nasync function fetchGeminiCliQuota(\n  accessToken: string,\n  projectId: string,\n): Promise<RetrieveUserQuotaResponse> {\n  const endpoint = ANTIGRAVITY_ENDPOINT_PROD;\n  // Use Gemini CLI user-agent to get CLI quota buckets (not Antigravity buckets)\n  const platform = process.platform || \"darwin\";\n  const arch = process.arch || \"arm64\";\n  const geminiCliUserAgent = `GeminiCLI/1.0.0/gemini-2.5-pro (${platform}; ${arch})`;\n\n  const body = projectId ? { project: projectId } : {};\n  \n  try {\n    const response = await fetchWithTimeout(`${endpoint}/v1internal:retrieveUserQuota`, {\n      method: \"POST\",\n      headers: {\n        Authorization: `Bearer ${accessToken}`,\n        \"Content-Type\": \"application/json\",\n        \"User-Agent\": geminiCliUserAgent,\n      },\n      body: JSON.stringify(body),\n    });\n\n    if (response.ok) {\n      const data = (await response.json()) as RetrieveUserQuotaResponse;\n      return data;\n    }\n\n    // Non-OK response - return empty buckets\n    return { buckets: [] };\n  } catch {\n    // Network error or timeout - return empty buckets\n    return { buckets: [] };\n  }\n}\n\nfunction aggregateGeminiCliQuota(response: RetrieveUserQuotaResponse): GeminiCliQuotaSummary {\n  const models: GeminiCliQuotaModel[] = [];\n  \n  if (!response.buckets || response.buckets.length === 0) {\n    return { models };\n  }\n\n  for (const bucket of response.buckets) {\n    if (!bucket.modelId) {\n      continue;\n    }\n    \n    // Filter out models we don't care about for Gemini CLI quotas\n    // Only show gemini-3-* and gemini-2.5-pro models (the premium ones)\n    const modelId = bucket.modelId;\n    const isRelevantModel = \n      modelId.startsWith(\"gemini-3-\") || \n      modelId === \"gemini-2.5-pro\";\n    \n    if (!isRelevantModel) {\n      continue;\n    }\n    \n    models.push({\n      modelId: bucket.modelId,\n      remainingFraction: normalizeRemainingFraction(bucket.remainingFraction),\n      resetTime: bucket.resetTime,\n    });\n  }\n\n  // Sort by model ID for consistent display\n  models.sort((a, b) => a.modelId.localeCompare(b.modelId));\n\n  return { models };\n}\n\nfunction applyAccountUpdates(account: AccountMetadataV3, auth: OAuthAuthDetails): AccountMetadataV3 | undefined {\n  const parts = parseRefreshParts(auth.refresh);\n  if (!parts.refreshToken) {\n    return undefined;\n  }\n\n  const updated: AccountMetadataV3 = {\n    ...account,\n    refreshToken: parts.refreshToken,\n    projectId: parts.projectId ?? account.projectId,\n    managedProjectId: parts.managedProjectId ?? account.managedProjectId,\n  };\n\n  const changed =\n    updated.refreshToken !== account.refreshToken ||\n    updated.projectId !== account.projectId ||\n    updated.managedProjectId !== account.managedProjectId;\n\n  return changed ? updated : undefined;\n}\n\nexport async function checkAccountsQuota(\n  accounts: AccountMetadataV3[],\n  client: PluginClient,\n  providerId = ANTIGRAVITY_PROVIDER_ID,\n): Promise<AccountQuotaResult[]> {\n  const results: AccountQuotaResult[] = [];\n  \n  logQuotaFetch(\"start\", accounts.length);\n\n  for (const [index, account] of accounts.entries()) {\n    const disabled = account.enabled === false;\n\n    let auth = buildAuthFromAccount(account);\n\n    try {\n      if (accessTokenExpired(auth)) {\n        const refreshed = await refreshAccessToken(auth, client, providerId);\n        if (!refreshed) {\n          throw new Error(\"Token refresh failed\");\n        }\n        auth = refreshed;\n      }\n\n      const projectContext = await ensureProjectContext(auth);\n      auth = projectContext.auth;\n      const updatedAccount = applyAccountUpdates(account, auth);\n\n      let quotaResult: QuotaSummary;\n      let geminiCliQuotaResult: GeminiCliQuotaSummary;\n      \n      // Fetch both Antigravity and Gemini CLI quotas in parallel\n      const [antigravityResponse, geminiCliResponse] = await Promise.all([\n        fetchAvailableModels(auth.access ?? \"\", projectContext.effectiveProjectId)\n          .catch((error): FetchAvailableModelsResponse => ({ models: undefined })),\n        fetchGeminiCliQuota(auth.access ?? \"\", projectContext.effectiveProjectId),\n      ]);\n\n      // Process Antigravity quota\n      if (antigravityResponse.models === undefined) {\n        quotaResult = {\n          groups: {},\n          modelCount: 0,\n          error: \"Failed to fetch Antigravity quota\",\n        };\n      } else {\n        quotaResult = aggregateQuota(antigravityResponse.models);\n      }\n\n      // Process Gemini CLI quota\n      geminiCliQuotaResult = aggregateGeminiCliQuota(geminiCliResponse);\n      if (geminiCliResponse.buckets === undefined || geminiCliResponse.buckets.length === 0) {\n        geminiCliQuotaResult.error = geminiCliQuotaResult.models.length === 0 \n          ? \"No Gemini CLI quota available\" \n          : undefined;\n      }\n\n      results.push({\n        index,\n        email: account.email,\n        status: \"ok\",\n        disabled,\n        quota: quotaResult,\n        geminiCliQuota: geminiCliQuotaResult,\n        updatedAccount,\n      });\n      \n      // Log quota status for each family\n      for (const [family, groupQuota] of Object.entries(quotaResult.groups)) {\n        const remainingPercent = (groupQuota.remainingFraction ?? 0) * 100;\n        logQuotaStatus(account.email, index, remainingPercent, family);\n      }\n    } catch (error) {\n      results.push({\n        index,\n        email: account.email,\n        status: \"error\",\n        disabled,\n        error: error instanceof Error ? error.message : String(error),\n      });\n      logQuotaFetch(\"error\", undefined, `account=${account.email ?? index} error=${error instanceof Error ? error.message : String(error)}`);\n    }\n  }\n\n  logQuotaFetch(\"complete\", accounts.length, `ok=${results.filter(r => r.status === \"ok\").length} errors=${results.filter(r => r.status === \"error\").length}`);\n  return results;\n}\n"
  },
  {
    "path": "src/plugin/recovery/constants.ts",
    "content": "/**\n * Constants for session recovery storage paths.\n * \n * Based on oh-my-opencode/src/hooks/session-recovery/constants.ts\n */\n\nimport { join } from \"node:path\";\nimport { homedir } from \"node:os\";\n\n/**\n * Get the XDG data directory for OpenCode storage.\n * Falls back to ~/.local/share on Linux/Mac, or APPDATA on Windows.\n */\nfunction getXdgData(): string {\n  const platform = process.platform;\n  \n  if (platform === \"win32\") {\n    return process.env.APPDATA || join(homedir(), \"AppData\", \"Roaming\");\n  }\n  \n  return process.env.XDG_DATA_HOME || join(homedir(), \".local\", \"share\");\n}\n\n/**\n * Get the XDG config directory for Antigravity config.\n * Falls back to ~/.config on Linux/Mac, or APPDATA on Windows.\n */\nexport function getXdgConfig(): string {\n  const platform = process.platform;\n  \n  if (platform === \"win32\") {\n    return process.env.APPDATA || join(homedir(), \"AppData\", \"Roaming\");\n  }\n  \n  return process.env.XDG_CONFIG_HOME || join(homedir(), \".config\");\n}\n\n/**\n * Get the Antigravity config directory.\n * Default: ~/.config/opencode/antigravity.json\n */\nexport function getAntigravityConfigDir(): string {\n  return join(getXdgConfig(), \"opencode\");\n}\n\nexport const OPENCODE_STORAGE = join(getXdgData(), \"opencode\", \"storage\");\nexport const MESSAGE_STORAGE = join(OPENCODE_STORAGE, \"message\");\nexport const PART_STORAGE = join(OPENCODE_STORAGE, \"part\");\n\nexport const THINKING_TYPES = new Set([\"thinking\", \"redacted_thinking\", \"reasoning\"]);\nexport const META_TYPES = new Set([\"step-start\", \"step-finish\"]);\nexport const CONTENT_TYPES = new Set([\"text\", \"tool\", \"tool_use\", \"tool_result\"]);\n"
  },
  {
    "path": "src/plugin/recovery/index.ts",
    "content": "/**\n * Session recovery module for opencode-antigravity-auth.\n * \n * Provides recovery from:\n * - tool_result_missing: Interrupted tool executions\n * - thinking_block_order: Corrupted thinking blocks\n * - thinking_disabled_violation: Thinking in non-thinking model\n */\n\nexport * from \"./types\";\nexport * from \"./constants\";\nexport * from \"./storage\";\n"
  },
  {
    "path": "src/plugin/recovery/storage.ts",
    "content": "/**\n * Storage utilities for reading OpenCode's session data.\n * \n * Based on oh-my-opencode/src/hooks/session-recovery/storage.ts\n */\n\nimport { existsSync, mkdirSync, readdirSync, readFileSync, unlinkSync, writeFileSync } from \"node:fs\";\nimport { join } from \"node:path\";\nimport { MESSAGE_STORAGE, PART_STORAGE, THINKING_TYPES, META_TYPES } from \"./constants\";\nimport type { StoredMessageMeta, StoredPart, StoredTextPart } from \"./types\";\n\n// =============================================================================\n// ID Generation\n// =============================================================================\n\nexport function generatePartId(): string {\n  const timestamp = Date.now().toString(16);\n  const random = Math.random().toString(36).substring(2, 10);\n  return `prt_${timestamp}${random}`;\n}\n\n// =============================================================================\n// Directory Helpers\n// =============================================================================\n\nexport function getMessageDir(sessionID: string): string {\n  if (!existsSync(MESSAGE_STORAGE)) return \"\";\n\n  const directPath = join(MESSAGE_STORAGE, sessionID);\n  if (existsSync(directPath)) {\n    return directPath;\n  }\n\n  // Search in subdirectories\n  try {\n    for (const dir of readdirSync(MESSAGE_STORAGE)) {\n      const sessionPath = join(MESSAGE_STORAGE, dir, sessionID);\n      if (existsSync(sessionPath)) {\n        return sessionPath;\n      }\n    }\n  } catch {\n    // Ignore read errors\n  }\n\n  return \"\";\n}\n\n// =============================================================================\n// Message Reading\n// =============================================================================\n\nexport function readMessages(sessionID: string): StoredMessageMeta[] {\n  const messageDir = getMessageDir(sessionID);\n  if (!messageDir || !existsSync(messageDir)) return [];\n\n  const messages: StoredMessageMeta[] = [];\n  try {\n    for (const file of readdirSync(messageDir)) {\n      if (!file.endsWith(\".json\")) continue;\n      try {\n        const content = readFileSync(join(messageDir, file), \"utf-8\");\n        messages.push(JSON.parse(content));\n      } catch {\n        continue;\n      }\n    }\n  } catch {\n    return [];\n  }\n\n  return messages.sort((a, b) => {\n    const aTime = a.time?.created ?? 0;\n    const bTime = b.time?.created ?? 0;\n    if (aTime !== bTime) return aTime - bTime;\n    return a.id.localeCompare(b.id);\n  });\n}\n\n// =============================================================================\n// Part Reading\n// =============================================================================\n\nexport function readParts(messageID: string): StoredPart[] {\n  const partDir = join(PART_STORAGE, messageID);\n  if (!existsSync(partDir)) return [];\n\n  const parts: StoredPart[] = [];\n  try {\n    for (const file of readdirSync(partDir)) {\n      if (!file.endsWith(\".json\")) continue;\n      try {\n        const content = readFileSync(join(partDir, file), \"utf-8\");\n        parts.push(JSON.parse(content));\n      } catch {\n        continue;\n      }\n    }\n  } catch {\n    return [];\n  }\n\n  return parts;\n}\n\n// =============================================================================\n// Content Helpers\n// =============================================================================\n\nexport function hasContent(part: StoredPart): boolean {\n  if (THINKING_TYPES.has(part.type)) return false;\n  if (META_TYPES.has(part.type)) return false;\n\n  if (part.type === \"text\") {\n    const textPart = part as StoredTextPart;\n    return !!(textPart.text?.trim());\n  }\n\n  if (part.type === \"tool\" || part.type === \"tool_use\") {\n    return true;\n  }\n\n  if (part.type === \"tool_result\") {\n    return true;\n  }\n\n  return false;\n}\n\nexport function messageHasContent(messageID: string): boolean {\n  const parts = readParts(messageID);\n  return parts.some(hasContent);\n}\n\n// =============================================================================\n// Part Injection (for recovery)\n// =============================================================================\n\nexport function injectTextPart(sessionID: string, messageID: string, text: string): boolean {\n  const partDir = join(PART_STORAGE, messageID);\n\n  try {\n    if (!existsSync(partDir)) {\n      mkdirSync(partDir, { recursive: true });\n    }\n\n    const partId = generatePartId();\n    const part: StoredTextPart = {\n      id: partId,\n      sessionID,\n      messageID,\n      type: \"text\",\n      text,\n      synthetic: true,\n    };\n\n    writeFileSync(join(partDir, `${partId}.json`), JSON.stringify(part, null, 2));\n    return true;\n  } catch {\n    return false;\n  }\n}\n\n// =============================================================================\n// Thinking Block Recovery\n// =============================================================================\n\nexport function findMessagesWithThinkingBlocks(sessionID: string): string[] {\n  const messages = readMessages(sessionID);\n  const result: string[] = [];\n\n  for (const msg of messages) {\n    if (msg.role !== \"assistant\") continue;\n\n    const parts = readParts(msg.id);\n    const hasThinking = parts.some((p) => THINKING_TYPES.has(p.type));\n    if (hasThinking) {\n      result.push(msg.id);\n    }\n  }\n\n  return result;\n}\n\nexport function findMessagesWithThinkingOnly(sessionID: string): string[] {\n  const messages = readMessages(sessionID);\n  const result: string[] = [];\n\n  for (const msg of messages) {\n    if (msg.role !== \"assistant\") continue;\n\n    const parts = readParts(msg.id);\n    if (parts.length === 0) continue;\n\n    const hasThinking = parts.some((p) => THINKING_TYPES.has(p.type));\n    const hasTextContent = parts.some(hasContent);\n\n    // Has thinking but no text content = orphan thinking\n    if (hasThinking && !hasTextContent) {\n      result.push(msg.id);\n    }\n  }\n\n  return result;\n}\n\nexport function findMessagesWithOrphanThinking(sessionID: string): string[] {\n  const messages = readMessages(sessionID);\n  const result: string[] = [];\n\n  for (let i = 0; i < messages.length; i++) {\n    const msg = messages[i];\n    if (!msg || msg.role !== \"assistant\") continue;\n\n    const parts = readParts(msg.id);\n    if (parts.length === 0) continue;\n\n    const sortedParts = [...parts].sort((a, b) => a.id.localeCompare(b.id));\n    const firstPart = sortedParts[0];\n    if (!firstPart) continue;\n\n    const firstIsThinking = THINKING_TYPES.has(firstPart.type);\n\n    // If first part is not thinking, it's orphan\n    if (!firstIsThinking) {\n      result.push(msg.id);\n    }\n  }\n\n  return result;\n}\n\nexport function prependThinkingPart(sessionID: string, messageID: string): boolean {\n  const partDir = join(PART_STORAGE, messageID);\n\n  try {\n    if (!existsSync(partDir)) {\n      mkdirSync(partDir, { recursive: true });\n    }\n\n    const partId = \"prt_0000000000_thinking\";\n    const part = {\n      id: partId,\n      sessionID,\n      messageID,\n      type: \"thinking\",\n      thinking: \"\",\n      synthetic: true,\n    };\n\n    writeFileSync(join(partDir, `${partId}.json`), JSON.stringify(part, null, 2));\n    return true;\n  } catch {\n    return false;\n  }\n}\n\nexport function stripThinkingParts(messageID: string): boolean {\n  const partDir = join(PART_STORAGE, messageID);\n  if (!existsSync(partDir)) return false;\n\n  let anyRemoved = false;\n  try {\n    for (const file of readdirSync(partDir)) {\n      if (!file.endsWith(\".json\")) continue;\n      try {\n        const filePath = join(partDir, file);\n        const content = readFileSync(filePath, \"utf-8\");\n        const part = JSON.parse(content) as StoredPart;\n        if (THINKING_TYPES.has(part.type)) {\n          unlinkSync(filePath);\n          anyRemoved = true;\n        }\n      } catch {\n        continue;\n      }\n    }\n  } catch {\n    return false;\n  }\n\n  return anyRemoved;\n}\n\n// =============================================================================\n// Empty Message Recovery\n// =============================================================================\n\nexport function findEmptyMessages(sessionID: string): string[] {\n  const messages = readMessages(sessionID);\n  const emptyIds: string[] = [];\n\n  for (const msg of messages) {\n    if (!messageHasContent(msg.id)) {\n      emptyIds.push(msg.id);\n    }\n  }\n\n  return emptyIds;\n}\n\nexport function findEmptyMessageByIndex(sessionID: string, targetIndex: number): string | null {\n  const messages = readMessages(sessionID);\n\n  // API index may differ from storage index due to system messages\n  const indicesToTry = [targetIndex, targetIndex - 1, targetIndex - 2];\n\n  for (const idx of indicesToTry) {\n    if (idx < 0 || idx >= messages.length) continue;\n\n    const targetMsg = messages[idx];\n    if (!targetMsg) continue;\n\n    if (!messageHasContent(targetMsg.id)) {\n      return targetMsg.id;\n    }\n  }\n\n  return null;\n}\n\nexport function findMessageByIndexNeedingThinking(sessionID: string, targetIndex: number): string | null {\n  const messages = readMessages(sessionID);\n\n  if (targetIndex < 0 || targetIndex >= messages.length) return null;\n\n  const targetMsg = messages[targetIndex];\n  if (!targetMsg || targetMsg.role !== \"assistant\") return null;\n\n  const parts = readParts(targetMsg.id);\n  if (parts.length === 0) return null;\n\n  const sortedParts = [...parts].sort((a, b) => a.id.localeCompare(b.id));\n  const firstPart = sortedParts[0];\n  if (!firstPart) return null;\n\n  const firstIsThinking = THINKING_TYPES.has(firstPart.type);\n\n  if (!firstIsThinking) {\n    return targetMsg.id;\n  }\n\n  return null;\n}\n\nexport function replaceEmptyTextParts(messageID: string, replacementText: string): boolean {\n  const partDir = join(PART_STORAGE, messageID);\n  if (!existsSync(partDir)) return false;\n\n  let anyReplaced = false;\n  try {\n    for (const file of readdirSync(partDir)) {\n      if (!file.endsWith(\".json\")) continue;\n      try {\n        const filePath = join(partDir, file);\n        const content = readFileSync(filePath, \"utf-8\");\n        const part = JSON.parse(content) as StoredPart;\n\n        if (part.type === \"text\") {\n          const textPart = part as StoredTextPart;\n          if (!textPart.text?.trim()) {\n            textPart.text = replacementText;\n            textPart.synthetic = true;\n            writeFileSync(filePath, JSON.stringify(textPart, null, 2));\n            anyReplaced = true;\n          }\n        }\n      } catch {\n        continue;\n      }\n    }\n  } catch {\n    return false;\n  }\n\n  return anyReplaced;\n}\n\nexport function findMessagesWithEmptyTextParts(sessionID: string): string[] {\n  const messages = readMessages(sessionID);\n  const result: string[] = [];\n\n  for (const msg of messages) {\n    const parts = readParts(msg.id);\n    const hasEmptyTextPart = parts.some((p) => {\n      if (p.type !== \"text\") return false;\n      const textPart = p as StoredTextPart;\n      return !textPart.text?.trim();\n    });\n\n    if (hasEmptyTextPart) {\n      result.push(msg.id);\n    }\n  }\n\n  return result;\n}\n"
  },
  {
    "path": "src/plugin/recovery/types.ts",
    "content": "/**\n * Types for session recovery.\n * \n * Based on oh-my-opencode/src/hooks/session-recovery/types.ts\n */\n\n// =============================================================================\n// Storage Types (for reading from OpenCode's filesystem)\n// =============================================================================\n\nexport type ThinkingPartType = \"thinking\" | \"redacted_thinking\" | \"reasoning\";\nexport type MetaPartType = \"step-start\" | \"step-finish\";\nexport type ContentPartType = \"text\" | \"tool\" | \"tool_use\" | \"tool_result\";\n\nexport interface StoredMessageMeta {\n  id: string;\n  sessionID: string;\n  role: \"user\" | \"assistant\";\n  parentID?: string;\n  time?: {\n    created: number;\n    completed?: number;\n  };\n  error?: unknown;\n}\n\nexport interface StoredTextPart {\n  id: string;\n  sessionID: string;\n  messageID: string;\n  type: \"text\";\n  text: string;\n  synthetic?: boolean;\n  ignored?: boolean;\n}\n\nexport interface StoredToolPart {\n  id: string;\n  sessionID: string;\n  messageID: string;\n  type: \"tool\";\n  callID: string;\n  tool: string;\n  state: {\n    status: \"pending\" | \"running\" | \"completed\" | \"error\";\n    input: Record<string, unknown>;\n    output?: string;\n    error?: string;\n  };\n}\n\nexport interface StoredReasoningPart {\n  id: string;\n  sessionID: string;\n  messageID: string;\n  type: \"reasoning\";\n  text: string;\n}\n\nexport interface StoredStepPart {\n  id: string;\n  sessionID: string;\n  messageID: string;\n  type: \"step-start\" | \"step-finish\";\n}\n\nexport type StoredPart = \n  | StoredTextPart \n  | StoredToolPart \n  | StoredReasoningPart \n  | StoredStepPart \n  | {\n      id: string;\n      sessionID: string;\n      messageID: string;\n      type: string;\n      [key: string]: unknown;\n    };\n\n// =============================================================================\n// API Types (for working with OpenCode SDK responses)\n// =============================================================================\n\nexport interface MessagePart {\n  type: string;\n  id?: string;\n  text?: string;\n  thinking?: string;\n  name?: string;\n  input?: Record<string, unknown>;\n  callID?: string;\n}\n\nexport interface MessageData {\n  info?: {\n    id?: string;\n    role?: string;\n    sessionID?: string;\n    parentID?: string;\n    error?: unknown;\n    agent?: string;\n    model?: {\n      providerID: string;\n      modelID: string;\n    };\n    system?: string;\n    tools?: Record<string, boolean>;\n  };\n  parts?: MessagePart[];\n}\n\nexport interface MessageInfo {\n  id?: string;\n  role?: string;\n  sessionID?: string;\n  parentID?: string;\n  error?: unknown;\n}\n\nexport interface ResumeConfig {\n  sessionID: string;\n  agent?: string;\n  model?: {\n    providerID: string;\n    modelID: string;\n  };\n}\n\n// =============================================================================\n// Hook Types\n// =============================================================================\n\nexport type RecoveryErrorType =\n  | \"tool_result_missing\"\n  | \"thinking_block_order\"\n  | \"thinking_disabled_violation\"\n  | null;\n\nexport interface ToolUsePart {\n  type: \"tool_use\";\n  id: string;\n  name: string;\n  input: Record<string, unknown>;\n}\n\nexport interface ToolResultPart {\n  type: \"tool_result\";\n  tool_use_id: string;\n  content: string;\n}\n"
  },
  {
    "path": "src/plugin/recovery.test.ts",
    "content": "import { describe, it, expect } from \"vitest\";\nimport { detectErrorType, isRecoverableError } from \"./recovery\";\n\ndescribe(\"detectErrorType\", () => {\n  describe(\"tool_result_missing detection\", () => {\n    it(\"detects tool_use without tool_result error\", () => {\n      const error = {\n        type: \"invalid_request_error\",\n        message: \"messages.105: `tool_use` ids were found without `tool_result` blocks immediately after: tool-call-59\"\n      };\n      expect(detectErrorType(error)).toBe(\"tool_result_missing\");\n    });\n\n    it(\"detects tool_use/tool_result mismatch error\", () => {\n      const error = \"Each `tool_use` block must have a corresponding `tool_result` block in the next message.\";\n      expect(detectErrorType(error)).toBe(\"tool_result_missing\");\n    });\n\n    it(\"detects error from string message\", () => {\n      const error = \"tool_use without matching tool_result\";\n      expect(detectErrorType(error)).toBe(\"tool_result_missing\");\n    });\n  });\n\n  describe(\"thinking_block_order detection\", () => {\n    it(\"detects thinking first block error\", () => {\n      const error = \"thinking must be the first block in the message\";\n      expect(detectErrorType(error)).toBe(\"thinking_block_order\");\n    });\n\n    it(\"detects thinking must start with error\", () => {\n      const error = \"Response must start with thinking block\";\n      expect(detectErrorType(error)).toBe(\"thinking_block_order\");\n    });\n\n    it(\"detects thinking preceeding error\", () => {\n      const error = \"thinking block preceeding tool use is required\";\n      expect(detectErrorType(error)).toBe(\"thinking_block_order\");\n    });\n\n    it(\"detects thinking expected/found error\", () => {\n      const error = \"Expected thinking block but found text\";\n      expect(detectErrorType(error)).toBe(\"thinking_block_order\");\n    });\n  });\n\n  describe(\"thinking_disabled_violation detection\", () => {\n    it(\"detects thinking disabled error\", () => {\n      const error = \"thinking is disabled for this model and cannot contain thinking blocks\";\n      expect(detectErrorType(error)).toBe(\"thinking_disabled_violation\");\n    });\n  });\n\n  describe(\"non-recoverable errors\", () => {\n    it(\"returns null for prompt too long error\", () => {\n      // This is handled separately, not as a recoverable error\n      const error = { message: \"Prompt is too long\" };\n      expect(detectErrorType(error)).toBeNull();\n    });\n\n    it(\"returns null for context length exceeded error\", () => {\n      const error = \"context length exceeded\";\n      expect(detectErrorType(error)).toBeNull();\n    });\n\n    it(\"returns null for generic errors\", () => {\n      expect(detectErrorType(\"Something went wrong\")).toBeNull();\n      expect(detectErrorType({ message: \"Unknown error\" })).toBeNull();\n      expect(detectErrorType(null)).toBeNull();\n      expect(detectErrorType(undefined)).toBeNull();\n    });\n\n    it(\"returns null for rate limit errors\", () => {\n      const error = { message: \"Rate limit exceeded. Retry after 5s\" };\n      expect(detectErrorType(error)).toBeNull();\n    });\n\n    it(\"returns null for generic INVALID_ARGUMENT with debug expected/found metadata\", () => {\n      const error = {\n        message:\n          \"Request contains an invalid argument. [Debug Info] Requested Model: antigravity-claude-opus-4-6-thinking Tool Debug Summary: expected=1 found=0\",\n      };\n      expect(detectErrorType(error)).toBeNull();\n    });\n  });\n});\n\ndescribe(\"isRecoverableError\", () => {\n  it(\"returns true for tool_result_missing\", () => {\n    const error = \"tool_use without tool_result\";\n    expect(isRecoverableError(error)).toBe(true);\n  });\n\n  it(\"returns true for thinking_block_order\", () => {\n    const error = \"thinking must be the first block\";\n    expect(isRecoverableError(error)).toBe(true);\n  });\n\n  it(\"returns true for thinking_disabled_violation\", () => {\n    const error = \"thinking is disabled and cannot contain thinking\";\n    expect(isRecoverableError(error)).toBe(true);\n  });\n\n  it(\"returns false for non-recoverable errors\", () => {\n    expect(isRecoverableError(\"Prompt is too long\")).toBe(false);\n    expect(isRecoverableError(\"context length exceeded\")).toBe(false);\n    expect(isRecoverableError(\"Generic error\")).toBe(false);\n    expect(isRecoverableError(null)).toBe(false);\n  });\n});\n\n// =============================================================================\n// CONTEXT ERROR MESSAGES\n// These test that error messages from the API can be properly categorized\n// =============================================================================\n\ndescribe(\"context error message patterns\", () => {\n  describe(\"prompt too long patterns\", () => {\n    const promptTooLongPatterns = [\n      \"Prompt is too long\",\n      \"prompt is too long for this model\",\n      \"The prompt is too long\",\n    ];\n\n    it.each(promptTooLongPatterns)(\"'%s' is not a recoverable error\", (msg) => {\n      expect(isRecoverableError(msg)).toBe(false);\n      expect(detectErrorType(msg)).toBeNull();\n    });\n  });\n\n  describe(\"context length exceeded patterns\", () => {\n    const contextLengthPatterns = [\n      \"context length exceeded\",\n      \"context_length_exceeded\",\n      \"maximum context length\",\n      \"exceeds the maximum context window\",\n    ];\n\n    it.each(contextLengthPatterns)(\"'%s' is not a recoverable error\", (msg) => {\n      expect(isRecoverableError(msg)).toBe(false);\n      expect(detectErrorType(msg)).toBeNull();\n    });\n  });\n\n  describe(\"tool pairing error patterns\", () => {\n    const toolPairingPatterns = [\n      \"tool_use ids were found without tool_result blocks immediately after\",\n      \"Each tool_use block must have a corresponding tool_result\",\n      \"tool_use without matching tool_result\",\n    ];\n\n    it.each(toolPairingPatterns)(\"'%s' is detected as tool_result_missing\", (msg) => {\n      expect(detectErrorType(msg)).toBe(\"tool_result_missing\");\n      expect(isRecoverableError(msg)).toBe(true);\n    });\n  });\n});\n"
  },
  {
    "path": "src/plugin/recovery.ts",
    "content": "/**\n * Session recovery hook for handling recoverable errors.\n * \n * Supports:\n * - tool_result_missing: When ESC is pressed during tool execution\n * - thinking_block_order: When thinking blocks are corrupted/stripped\n * - thinking_disabled_violation: Thinking in non-thinking model\n * \n * Based on oh-my-opencode/src/hooks/session-recovery/index.ts\n */\n\nimport type { AntigravityConfig } from \"./config\";\nimport { createLogger } from \"./logger\";\nimport { logToast } from \"./debug\";\nimport type { PluginClient } from \"./types\";\nimport {\n  readParts,\n  findMessagesWithThinkingBlocks,\n  findMessagesWithOrphanThinking,\n  findMessageByIndexNeedingThinking,\n  prependThinkingPart,\n  stripThinkingParts,\n} from \"./recovery/storage\";\nimport type {\n  MessageInfo,\n  MessageData,\n  MessagePart,\n  RecoveryErrorType,\n  ResumeConfig,\n} from \"./recovery/types\";\n\n// =============================================================================\n// Constants\n// =============================================================================\n\nconst RECOVERY_RESUME_TEXT = \"[session recovered - continuing previous task]\";\n\n// =============================================================================\n// Error Detection\n// =============================================================================\n\n/**\n * Extract a normalized error message string from an unknown error.\n */\nfunction getErrorMessage(error: unknown): string {\n  if (!error) return \"\";\n  if (typeof error === \"string\") return error.toLowerCase();\n\n  const errorObj = error as Record<string, unknown>;\n  const paths = [\n    errorObj.data,\n    errorObj.error,\n    errorObj,\n    (errorObj.data as Record<string, unknown>)?.error,\n  ];\n\n  for (const obj of paths) {\n    if (obj && typeof obj === \"object\") {\n      const msg = (obj as Record<string, unknown>).message;\n      if (typeof msg === \"string\" && msg.length > 0) {\n        return msg.toLowerCase();\n      }\n    }\n  }\n\n  try {\n    return JSON.stringify(error).toLowerCase();\n  } catch {\n    return \"\";\n  }\n}\n\n/**\n * Extract the message index from an error message (e.g., \"messages.79\").\n */\nfunction extractMessageIndex(error: unknown): number | null {\n  const message = getErrorMessage(error);\n  const match = message.match(/messages\\.(\\d+)/);\n  if (!match || !match[1]) return null;\n  return parseInt(match[1], 10);\n}\n\n/**\n * Detect the type of recoverable error from an error object.\n */\nexport function detectErrorType(error: unknown): RecoveryErrorType {\n  const message = getErrorMessage(error);\n  const hasExpectedFoundThinkingOrder =\n    (message.includes(\"expected thinking\") || message.includes(\"expected a thinking\")) &&\n    message.includes(\"found\");\n\n  // tool_result_missing: Happens when ESC is pressed during tool execution\n  if (message.includes(\"tool_use\") && message.includes(\"tool_result\")) {\n    return \"tool_result_missing\";\n  }\n\n  // thinking_block_order: Happens when thinking blocks are corrupted\n  if (\n    message.includes(\"thinking\") &&\n    (message.includes(\"first block\") ||\n      message.includes(\"must start with\") ||\n      message.includes(\"preceeding\") ||\n      message.includes(\"preceding\") ||\n      hasExpectedFoundThinkingOrder)\n  ) {\n    return \"thinking_block_order\";\n  }\n\n  // thinking_disabled_violation: Thinking in non-thinking model\n  if (message.includes(\"thinking is disabled\") && message.includes(\"cannot contain\")) {\n    return \"thinking_disabled_violation\";\n  }\n\n  return null;\n}\n\n/**\n * Check if an error is recoverable.\n */\nexport function isRecoverableError(error: unknown): boolean {\n  return detectErrorType(error) !== null;\n}\n\n// =============================================================================\n// Tool Use Extraction\n// =============================================================================\n\ninterface ToolUsePart {\n  type: \"tool_use\";\n  id: string;\n  name: string;\n  input: Record<string, unknown>;\n}\n\nfunction extractToolUseIds(parts: MessagePart[]): string[] {\n  return parts\n    .filter((p): p is ToolUsePart & MessagePart => p.type === \"tool_use\" && !!p.id)\n    .map((p) => p.id!);\n}\n\n// =============================================================================\n// Recovery Functions\n// =============================================================================\n\n/**\n * Recover from tool_result_missing error by injecting synthetic tool_result blocks.\n */\nasync function recoverToolResultMissing(\n  client: PluginClient,\n  sessionID: string,\n  failedMsg: MessageData\n): Promise<boolean> {\n  // Try API parts first, fallback to filesystem if empty\n  let parts = failedMsg.parts || [];\n  if (parts.length === 0 && failedMsg.info?.id) {\n    const storedParts = readParts(failedMsg.info.id);\n    parts = storedParts.map((p) => ({\n      type: p.type === \"tool\" ? \"tool_use\" : p.type,\n      id: \"callID\" in p ? (p as { callID?: string }).callID : p.id,\n      name: \"tool\" in p ? (p as { tool?: string }).tool : undefined,\n      input: \"state\" in p ? (p as { state?: { input?: Record<string, unknown> } }).state?.input : undefined,\n    }));\n  }\n\n  const toolUseIds = extractToolUseIds(parts);\n\n  if (toolUseIds.length === 0) {\n    return false;\n  }\n\n  const toolResultParts = toolUseIds.map((id) => ({\n    type: \"tool_result\" as const,\n    tool_use_id: id,\n    content: \"Operation cancelled by user (ESC pressed)\",\n  }));\n\n  try {\n    await client.session.prompt({\n      path: { id: sessionID },\n      // @ts-expect-error - SDK types may not include tool_result parts\n      body: { parts: toolResultParts },\n    });\n\n    return true;\n  } catch {\n    return false;\n  }\n}\n\n/**\n * Recover from thinking_block_order error by prepending thinking parts.\n */\nasync function recoverThinkingBlockOrder(\n  sessionID: string,\n  _failedMsg: MessageData,\n  error: unknown\n): Promise<boolean> {\n  // Try to find the target message index from error\n  const targetIndex = extractMessageIndex(error);\n  if (targetIndex !== null) {\n    const targetMessageID = findMessageByIndexNeedingThinking(sessionID, targetIndex);\n    if (targetMessageID) {\n      return prependThinkingPart(sessionID, targetMessageID);\n    }\n  }\n\n  // Fallback: find all orphan thinking messages\n  const orphanMessages = findMessagesWithOrphanThinking(sessionID);\n\n  if (orphanMessages.length === 0) {\n    return false;\n  }\n\n  let anySuccess = false;\n  for (const messageID of orphanMessages) {\n    if (prependThinkingPart(sessionID, messageID)) {\n      anySuccess = true;\n    }\n  }\n\n  return anySuccess;\n}\n\n/**\n * Recover from thinking_disabled_violation by stripping thinking parts.\n */\nasync function recoverThinkingDisabledViolation(\n  sessionID: string,\n  _failedMsg: MessageData\n): Promise<boolean> {\n  const messagesWithThinking = findMessagesWithThinkingBlocks(sessionID);\n\n  if (messagesWithThinking.length === 0) {\n    return false;\n  }\n\n  let anySuccess = false;\n  for (const messageID of messagesWithThinking) {\n    if (stripThinkingParts(messageID)) {\n      anySuccess = true;\n    }\n  }\n\n  return anySuccess;\n}\n\n// =============================================================================\n// Resume Session Helper\n// =============================================================================\n\nfunction findLastUserMessage(messages: MessageData[]): MessageData | undefined {\n  for (let i = messages.length - 1; i >= 0; i--) {\n    if (messages[i]?.info?.role === \"user\") {\n      return messages[i];\n    }\n  }\n  return undefined;\n}\n\nfunction extractResumeConfig(userMessage: MessageData | undefined, sessionID: string): ResumeConfig {\n  return {\n    sessionID,\n    agent: userMessage?.info?.agent,\n    model: userMessage?.info?.model,\n  };\n}\n\nasync function resumeSession(\n  client: PluginClient,\n  config: ResumeConfig,\n  directory: string\n): Promise<boolean> {\n  try {\n    await client.session.prompt({\n      path: { id: config.sessionID },\n      body: {\n        parts: [{ type: \"text\", text: RECOVERY_RESUME_TEXT }],\n        agent: config.agent,\n        model: config.model,\n      },\n      query: { directory },\n    });\n    return true;\n  } catch {\n    return false;\n  }\n}\n\n// =============================================================================\n// Toast Messages\n// =============================================================================\n\nconst TOAST_TITLES: Record<string, string> = {\n  tool_result_missing: \"Tool Crash Recovery\",\n  thinking_block_order: \"Thinking Block Recovery\",\n  thinking_disabled_violation: \"Thinking Strip Recovery\",\n};\n\nconst TOAST_MESSAGES: Record<string, string> = {\n  tool_result_missing: \"Injecting cancelled tool results...\",\n  thinking_block_order: \"Fixing message structure...\",\n  thinking_disabled_violation: \"Stripping thinking blocks...\",\n};\n\nexport function getRecoveryToastContent(errorType: RecoveryErrorType): {\n  title: string;\n  message: string;\n} {\n  if (!errorType) {\n    return {\n      title: \"Session Recovery\",\n      message: \"Attempting to recover session...\",\n    };\n  }\n  return {\n    title: TOAST_TITLES[errorType] || \"Session Recovery\",\n    message: TOAST_MESSAGES[errorType] || \"Attempting to recover session...\",\n  };\n}\n\nexport function getRecoverySuccessToast(): {\n  title: string;\n  message: string;\n} {\n  return {\n    title: \"Session Recovered\",\n    message: \"Continuing where you left off...\",\n  };\n}\n\nexport function getRecoveryFailureToast(): {\n  title: string;\n  message: string;\n} {\n  return {\n    title: \"Recovery Failed\",\n    message: \"Please retry or start a new session.\",\n  };\n}\n\n// =============================================================================\n// Session Recovery Hook\n// =============================================================================\n\nexport interface SessionRecoveryHook {\n  /**\n   * Main recovery handler. Performs the actual fix.\n   * Returns true if recovery was successful.\n   */\n  handleSessionRecovery: (info: MessageInfo) => Promise<boolean>;\n\n  /**\n   * Check if the error is recoverable.\n   */\n  isRecoverableError: (error: unknown) => boolean;\n\n  /**\n   * Callback for when a session is being aborted for recovery.\n   */\n  setOnAbortCallback: (callback: (sessionID: string) => void) => void;\n\n  /**\n   * Callback for when recovery is complete (success or failure).\n   */\n  setOnRecoveryCompleteCallback: (callback: (sessionID: string) => void) => void;\n}\n\nexport interface SessionRecoveryContext {\n  client: PluginClient;\n  directory: string;\n}\n\n/**\n * Create a session recovery hook with the given configuration.\n */\nexport function createSessionRecoveryHook(\n  ctx: SessionRecoveryContext,\n  config: AntigravityConfig\n): SessionRecoveryHook | null {\n  // If session recovery is disabled, return null\n  if (!config.session_recovery) {\n    return null;\n  }\n\n  const { client, directory } = ctx;\n  const processingErrors = new Set<string>();\n  let onAbortCallback: ((sessionID: string) => void) | null = null;\n  let onRecoveryCompleteCallback: ((sessionID: string) => void) | null = null;\n\n  const setOnAbortCallback = (callback: (sessionID: string) => void): void => {\n    onAbortCallback = callback;\n  };\n\n  const setOnRecoveryCompleteCallback = (callback: (sessionID: string) => void): void => {\n    onRecoveryCompleteCallback = callback;\n  };\n\n  const handleSessionRecovery = async (info: MessageInfo): Promise<boolean> => {\n    // Validate input\n    if (!info || info.role !== \"assistant\" || !info.error) return false;\n\n    const errorType = detectErrorType(info.error);\n    if (!errorType) return false;\n\n    const sessionID = info.sessionID;\n    if (!sessionID) return false;\n\n    // OpenCode's session.error event may not include messageID\n    // In that case, we need to fetch messages and find the latest assistant with error\n    let assistantMsgID = info.id;\n    let msgs: MessageData[] | undefined;\n    const log = createLogger(\"session-recovery\");\n\n    log.debug(\"Recovery attempt started\", {\n      errorType,\n      sessionID,\n      providedMsgID: assistantMsgID ?? \"none\",\n    });\n\n    // Notify abort callback early\n    if (onAbortCallback) {\n      onAbortCallback(sessionID);\n    }\n\n    // Abort current request\n    await client.session.abort({ path: { id: sessionID } }).catch(() => {});\n\n    // Fetch messages - needed to find the failed message\n    const messagesResp = await client.session.messages({\n      path: { id: sessionID },\n      query: { directory },\n    });\n    msgs = (messagesResp as { data?: MessageData[] }).data;\n\n    // If messageID wasn't provided, find the latest assistant message with an error\n    if (!assistantMsgID && msgs && msgs.length > 0) {\n      // Find the last assistant message (most recent is typically last in array)\n      for (let i = msgs.length - 1; i >= 0; i--) {\n        const m = msgs[i];\n        if (m && m.info?.role === \"assistant\" && m.info?.id) {\n          assistantMsgID = m.info.id;\n          log.debug(\"Found assistant message ID from session messages\", {\n            msgID: assistantMsgID,\n            msgIndex: i,\n          });\n          break;\n        }\n      }\n    }\n\n    if (!assistantMsgID) {\n      log.debug(\"No assistant message ID found, cannot recover\");\n      return false;\n    }\n    if (processingErrors.has(assistantMsgID)) return false;\n    processingErrors.add(assistantMsgID);\n\n    try {\n      const failedMsg = msgs?.find((m) => m.info?.id === assistantMsgID);\n      if (!failedMsg) {\n        return false;\n      }\n\n      // Show toast notification\n      const toastContent = getRecoveryToastContent(errorType);\n      logToast(`${toastContent.title}: ${toastContent.message}`, \"warning\");\n      await client.tui\n        .showToast({\n          body: {\n            title: toastContent.title,\n            message: toastContent.message,\n            variant: \"warning\",\n          },\n        })\n        .catch(() => {});\n\n      // Perform recovery based on error type\n      let success = false;\n\n      if (errorType === \"tool_result_missing\") {\n        success = await recoverToolResultMissing(client, sessionID, failedMsg);\n      } else if (errorType === \"thinking_block_order\") {\n        success = await recoverThinkingBlockOrder(sessionID, failedMsg, info.error);\n        if (success && config.auto_resume) {\n          const lastUser = findLastUserMessage(msgs ?? []);\n          const resumeConfig = extractResumeConfig(lastUser, sessionID);\n          await resumeSession(client, resumeConfig, directory);\n        }\n      } else if (errorType === \"thinking_disabled_violation\") {\n        success = await recoverThinkingDisabledViolation(sessionID, failedMsg);\n        if (success && config.auto_resume) {\n          const lastUser = findLastUserMessage(msgs ?? []);\n          const resumeConfig = extractResumeConfig(lastUser, sessionID);\n          await resumeSession(client, resumeConfig, directory);\n        }\n      }\n\n      return success;\n    } catch (err) {\n      log.error(\"Recovery failed\", { error: String(err) });\n      return false;\n    } finally {\n      processingErrors.delete(assistantMsgID);\n\n      // Always notify recovery complete\n      if (sessionID && onRecoveryCompleteCallback) {\n        onRecoveryCompleteCallback(sessionID);\n      }\n    }\n  };\n\n  return {\n    handleSessionRecovery,\n    isRecoverableError,\n    setOnAbortCallback,\n    setOnRecoveryCompleteCallback,\n  };\n}\n"
  },
  {
    "path": "src/plugin/refresh-queue.test.ts",
    "content": "import { beforeEach, describe, expect, it, vi } from \"vitest\";\nimport { ProactiveRefreshQueue } from \"./refresh-queue\";\nimport { AccountManager } from \"./accounts\";\nimport type { AccountStorageV4 } from \"./storage\";\nimport type { PluginClient } from \"./types\";\n\n// Mock PluginClient\nconst mockClient: PluginClient = {\n  toast: vi.fn(),\n  auth: {\n    get: vi.fn(),\n    set: vi.fn(),\n    remove: vi.fn(),\n  },\n} as unknown as PluginClient;\n\ndescribe(\"ProactiveRefreshQueue\", () => {\n  beforeEach(() => {\n    vi.useRealTimers();\n  });\n\n  describe(\"getAccountsNeedingRefresh\", () => {\n    it(\"skips disabled accounts\", () => {\n      const now = Date.now();\n      const stored: AccountStorageV4 = {\n        version: 4,\n        accounts: [\n          {\n            refreshToken: \"r1\",\n            projectId: \"p1\",\n            addedAt: now,\n            lastUsed: 0,\n            enabled: true,\n          },\n          {\n            refreshToken: \"r2\",\n            projectId: \"p2\",\n            addedAt: now,\n            lastUsed: 0,\n            enabled: false, // disabled account\n          },\n          {\n            refreshToken: \"r3\",\n            projectId: \"p3\",\n            addedAt: now,\n            lastUsed: 0,\n            enabled: true,\n          },\n        ],\n        activeIndex: 0,\n      };\n\n      const manager = new AccountManager(undefined, stored);\n      const queue = new ProactiveRefreshQueue(mockClient, \"test-provider\", {\n        enabled: true,\n        bufferSeconds: 1800,\n        checkIntervalSeconds: 300,\n      });\n      queue.setAccountManager(manager);\n\n      // Set all accounts to expire soon (within buffer)\n      const accounts = manager.getAccounts();\n      const expiringSoon = now + 1000 * 60 * 10; // 10 minutes from now\n      accounts.forEach((acc) => {\n        acc.expires = expiringSoon;\n      });\n\n      const needsRefresh = queue.getAccountsNeedingRefresh();\n\n      // Should only include enabled accounts (indices 0 and 2)\n      expect(needsRefresh.length).toBe(2);\n      expect(needsRefresh.map((a) => a.index)).toEqual([0, 2]);\n      expect(needsRefresh.every((a) => a.enabled !== false)).toBe(true);\n    });\n\n    it(\"includes accounts with undefined enabled (default to enabled)\", () => {\n      const now = Date.now();\n      const stored: AccountStorageV4 = {\n        version: 4,\n        accounts: [\n          {\n            refreshToken: \"r1\",\n            projectId: \"p1\",\n            addedAt: now,\n            lastUsed: 0,\n            // enabled is undefined - should be treated as enabled\n          },\n        ],\n        activeIndex: 0,\n      };\n\n      const manager = new AccountManager(undefined, stored);\n      const queue = new ProactiveRefreshQueue(mockClient, \"test-provider\", {\n        enabled: true,\n        bufferSeconds: 1800,\n        checkIntervalSeconds: 300,\n      });\n      queue.setAccountManager(manager);\n\n      // Set account to expire soon\n      const accounts = manager.getAccounts();\n      accounts[0]!.expires = now + 1000 * 60 * 10; // 10 minutes from now\n\n      const needsRefresh = queue.getAccountsNeedingRefresh();\n\n      expect(needsRefresh.length).toBe(1);\n      expect(needsRefresh[0]!.index).toBe(0);\n    });\n\n    it(\"skips expired accounts\", () => {\n      const now = Date.now();\n      const stored: AccountStorageV4 = {\n        version: 4,\n        accounts: [\n          {\n            refreshToken: \"r1\",\n            projectId: \"p1\",\n            addedAt: now,\n            lastUsed: 0,\n            enabled: true,\n          },\n        ],\n        activeIndex: 0,\n      };\n\n      const manager = new AccountManager(undefined, stored);\n      const queue = new ProactiveRefreshQueue(mockClient, \"test-provider\", {\n        enabled: true,\n        bufferSeconds: 1800,\n        checkIntervalSeconds: 300,\n      });\n      queue.setAccountManager(manager);\n\n      // Set account to already expired\n      const accounts = manager.getAccounts();\n      accounts[0]!.expires = now - 1000; // 1 second ago\n\n      const needsRefresh = queue.getAccountsNeedingRefresh();\n\n      expect(needsRefresh.length).toBe(0);\n    });\n\n    it(\"skips accounts that don't need refresh yet\", () => {\n      const now = Date.now();\n      const stored: AccountStorageV4 = {\n        version: 4,\n        accounts: [\n          {\n            refreshToken: \"r1\",\n            projectId: \"p1\",\n            addedAt: now,\n            lastUsed: 0,\n            enabled: true,\n          },\n        ],\n        activeIndex: 0,\n      };\n\n      const manager = new AccountManager(undefined, stored);\n      const queue = new ProactiveRefreshQueue(mockClient, \"test-provider\", {\n        enabled: true,\n        bufferSeconds: 1800, // 30 minutes\n        checkIntervalSeconds: 300,\n      });\n      queue.setAccountManager(manager);\n\n      // Set account to expire in 1 hour (outside 30 min buffer)\n      const accounts = manager.getAccounts();\n      accounts[0]!.expires = now + 1000 * 60 * 60; // 1 hour from now\n\n      const needsRefresh = queue.getAccountsNeedingRefresh();\n\n      expect(needsRefresh.length).toBe(0);\n    });\n  });\n});\n"
  },
  {
    "path": "src/plugin/refresh-queue.ts",
    "content": "/**\n * Proactive Token Refresh Queue\n * \n * Ported from LLM-API-Key-Proxy's BackgroundRefresher.\n * \n * This module provides background token refresh to ensure OAuth tokens\n * remain valid without blocking user requests. It periodically checks\n * all accounts and refreshes tokens that are approaching expiry.\n * \n * Features:\n * - Non-blocking background refresh (doesn't block requests)\n * - Configurable refresh buffer (default: 30 minutes before expiry)\n * - Configurable check interval (default: 5 minutes)\n * - Serialized refresh to prevent concurrent refresh storms\n * - Integrates with existing AccountManager and token refresh logic\n * - Silent operation: no console output, uses structured logger\n */\n\nimport type { AccountManager, ManagedAccount } from \"./accounts\";\nimport type { PluginClient, OAuthAuthDetails } from \"./types\";\nimport { refreshAccessToken } from \"./token\";\nimport { createLogger } from \"./logger\";\n\nconst log = createLogger(\"refresh-queue\");\n\n/** Configuration for the proactive refresh queue */\nexport interface ProactiveRefreshConfig {\n  /** Enable proactive token refresh (default: true) */\n  enabled: boolean;\n  /** Seconds before expiry to trigger proactive refresh (default: 1800 = 30 minutes) */\n  bufferSeconds: number;\n  /** Interval between refresh checks in seconds (default: 300 = 5 minutes) */\n  checkIntervalSeconds: number;\n}\n\nexport const DEFAULT_PROACTIVE_REFRESH_CONFIG: ProactiveRefreshConfig = {\n  enabled: true,\n  bufferSeconds: 1800, // 30 minutes\n  checkIntervalSeconds: 300, // 5 minutes\n};\n\n/** State for tracking refresh operations */\ninterface RefreshQueueState {\n  isRunning: boolean;\n  intervalHandle: ReturnType<typeof setInterval> | null;\n  isRefreshing: boolean;\n  lastCheckTime: number;\n  lastRefreshTime: number;\n  refreshCount: number;\n  errorCount: number;\n}\n\n/**\n * Proactive Token Refresh Queue\n * \n * Runs in the background and proactively refreshes tokens before they expire.\n * This ensures that user requests never block on token refresh.\n * \n * All logging is silent by default - uses structured logger with TUI integration.\n */\nexport class ProactiveRefreshQueue {\n  private readonly config: ProactiveRefreshConfig;\n  private readonly client: PluginClient;\n  private readonly providerId: string;\n  private accountManager: AccountManager | null = null;\n  \n  private state: RefreshQueueState = {\n    isRunning: false,\n    intervalHandle: null,\n    isRefreshing: false,\n    lastCheckTime: 0,\n    lastRefreshTime: 0,\n    refreshCount: 0,\n    errorCount: 0,\n  };\n\n  constructor(\n    client: PluginClient,\n    providerId: string,\n    config?: Partial<ProactiveRefreshConfig>,\n  ) {\n    this.client = client;\n    this.providerId = providerId;\n    this.config = {\n      ...DEFAULT_PROACTIVE_REFRESH_CONFIG,\n      ...config,\n    };\n  }\n\n  /**\n   * Set the account manager to use for refresh operations.\n   * Must be called before start().\n   */\n  setAccountManager(manager: AccountManager): void {\n    this.accountManager = manager;\n  }\n\n  /**\n   * Check if a token needs proactive refresh.\n   * Returns true if the token expires within the buffer period.\n   */\n  needsRefresh(account: ManagedAccount): boolean {\n    if (!account.expires) {\n      // No expiry set - assume it's fine\n      return false;\n    }\n\n    const now = Date.now();\n    const bufferMs = this.config.bufferSeconds * 1000;\n    const refreshThreshold = now + bufferMs;\n\n    return account.expires <= refreshThreshold;\n  }\n\n  /**\n   * Check if a token is already expired.\n   */\n  isExpired(account: ManagedAccount): boolean {\n    if (!account.expires) {\n      return false;\n    }\n    return account.expires <= Date.now();\n  }\n\n  /**\n   * Get all accounts that need proactive refresh.\n   */\n  getAccountsNeedingRefresh(): ManagedAccount[] {\n    if (!this.accountManager) {\n      return [];\n    }\n\n    return this.accountManager.getAccounts().filter((account) => {\n      // Skip disabled accounts - they shouldn't receive proactive refresh\n      if (account.enabled === false) {\n        return false;\n      }\n      // Only refresh if not already expired (let the main flow handle expired tokens)\n      if (this.isExpired(account)) {\n        return false;\n      }\n      return this.needsRefresh(account);\n    });\n  }\n\n  /**\n   * Perform a single refresh check iteration.\n   * This is called periodically by the background interval.\n   */\n  private async runRefreshCheck(): Promise<void> {\n    if (this.state.isRefreshing) {\n      // Already refreshing - skip this iteration\n      return;\n    }\n\n    if (!this.accountManager) {\n      return;\n    }\n\n    this.state.isRefreshing = true;\n    this.state.lastCheckTime = Date.now();\n\n    try {\n      const accountsToRefresh = this.getAccountsNeedingRefresh();\n\n      if (accountsToRefresh.length === 0) {\n        return;\n      }\n\n      log.debug(\"Found accounts needing refresh\", { count: accountsToRefresh.length });\n\n      // Refresh accounts serially to avoid concurrent refresh storms\n      for (const account of accountsToRefresh) {\n        if (!this.state.isRunning) {\n          // Queue was stopped - abort\n          break;\n        }\n\n        try {\n          const auth = this.accountManager.toAuthDetails(account);\n          const refreshed = await this.refreshToken(auth, account);\n\n          if (refreshed) {\n            this.accountManager.updateFromAuth(account, refreshed);\n            this.state.refreshCount++;\n            this.state.lastRefreshTime = Date.now();\n\n            // Persist the refreshed token\n            try {\n              await this.accountManager.saveToDisk();\n            } catch {\n              // Non-fatal - token is refreshed in memory\n            }\n          }\n        } catch (error) {\n          this.state.errorCount++;\n          // Log but don't throw - continue with other accounts\n          log.warn(\"Failed to refresh account\", {\n            accountIndex: account.index,\n            error: error instanceof Error ? error.message : String(error),\n          });\n        }\n      }\n    } finally {\n      this.state.isRefreshing = false;\n    }\n  }\n\n  /**\n   * Refresh a single token.\n   */\n  private async refreshToken(\n    auth: OAuthAuthDetails,\n    account: ManagedAccount,\n  ): Promise<OAuthAuthDetails | undefined> {\n    const minutesUntilExpiry = account.expires\n      ? Math.round((account.expires - Date.now()) / 60000)\n      : \"unknown\";\n\n    log.debug(\"Proactively refreshing token\", {\n      accountIndex: account.index,\n      email: account.email ?? \"unknown\",\n      minutesUntilExpiry,\n    });\n\n    return refreshAccessToken(auth, this.client, this.providerId);\n  }\n\n  /**\n   * Start the background refresh queue.\n   */\n  start(): void {\n    if (this.state.isRunning) {\n      return;\n    }\n\n    if (!this.config.enabled) {\n      log.debug(\"Proactive refresh disabled by config\");\n      return;\n    }\n\n    this.state.isRunning = true;\n    const intervalMs = this.config.checkIntervalSeconds * 1000;\n\n    log.debug(\"Started proactive refresh queue\", {\n      checkIntervalSeconds: this.config.checkIntervalSeconds,\n      bufferSeconds: this.config.bufferSeconds,\n    });\n\n    // Run initial check after a short delay (let things settle)\n    setTimeout(() => {\n      if (this.state.isRunning) {\n        this.runRefreshCheck().catch((error) => {\n          log.error(\"Initial check failed\", {\n            error: error instanceof Error ? error.message : String(error),\n          });\n        });\n      }\n    }, 5000);\n\n    // Set up periodic checks\n    this.state.intervalHandle = setInterval(() => {\n      this.runRefreshCheck().catch((error) => {\n        log.error(\"Check failed\", {\n          error: error instanceof Error ? error.message : String(error),\n        });\n      });\n    }, intervalMs);\n  }\n\n  /**\n   * Stop the background refresh queue.\n   */\n  stop(): void {\n    if (!this.state.isRunning) {\n      return;\n    }\n\n    this.state.isRunning = false;\n\n    if (this.state.intervalHandle) {\n      clearInterval(this.state.intervalHandle);\n      this.state.intervalHandle = null;\n    }\n\n    log.debug(\"Stopped proactive refresh queue\", {\n      refreshCount: this.state.refreshCount,\n      errorCount: this.state.errorCount,\n    });\n  }\n\n  /**\n   * Get current queue statistics.\n   */\n  getStats(): {\n    isRunning: boolean;\n    isRefreshing: boolean;\n    lastCheckTime: number;\n    lastRefreshTime: number;\n    refreshCount: number;\n    errorCount: number;\n  } {\n    return { ...this.state };\n  }\n\n  /**\n   * Check if the queue is currently running.\n   */\n  isRunning(): boolean {\n    return this.state.isRunning;\n  }\n}\n\n/**\n * Create a proactive refresh queue instance.\n */\nexport function createProactiveRefreshQueue(\n  client: PluginClient,\n  providerId: string,\n  config?: Partial<ProactiveRefreshConfig>,\n): ProactiveRefreshQueue {\n  return new ProactiveRefreshQueue(client, providerId, config);\n}\n"
  },
  {
    "path": "src/plugin/request-helpers.test.ts",
    "content": "import { describe, expect, it } from \"vitest\";\n\nimport {\n  isThinkingCapableModel,\n  extractThinkingConfig,\n  extractVariantThinkingConfig,\n  resolveThinkingConfig,\n  filterUnsignedThinkingBlocks,\n  filterMessagesThinkingBlocks,\n  deepFilterThinkingBlocks,\n  transformThinkingParts,\n  normalizeThinkingConfig,\n  parseAntigravityApiBody,\n  extractUsageMetadata,\n  extractUsageFromSsePayload,\n  rewriteAntigravityPreviewAccessError,\n  DEFAULT_THINKING_BUDGET,\n  findOrphanedToolUseIds,\n  fixClaudeToolPairing,\n  validateAndFixClaudeToolPairing,\n  injectParameterSignatures,\n  injectToolHardeningInstruction,\n  cleanJSONSchemaForAntigravity,\n  createSyntheticErrorResponse,\n  recursivelyParseJsonStrings,\n} from \"./request-helpers\";\nimport { deduplicateThinkingText, createThoughtBuffer } from \"./core/streaming/transformer\";\n\ndescribe(\"sanitizeThinkingPart (covered via filtering)\", () => {\n  it(\"extracts wrapped text and strips SDK fields for Gemini-style thought blocks\", () => {\n    const validSignature = \"s\".repeat(60);\n    const thinkingText = \"wrapped thought\";\n    const getCachedSignatureFn = (_sessionId: string, text: string) =>\n      text === thinkingText ? validSignature : undefined;\n\n    const contents = [\n      {\n        role: \"model\",\n        parts: [\n          {\n            thought: true,\n            text: {\n              text: thinkingText,\n              cache_control: { type: \"ephemeral\" },\n              providerOptions: { injected: true },\n            },\n            thoughtSignature: validSignature,\n            cache_control: { type: \"ephemeral\" },\n            providerOptions: { injected: true },\n          },\n        ],\n      },\n      { role: \"model\", parts: [{ text: \"trailing\" }] },\n    ];\n\n    const result = filterUnsignedThinkingBlocks(contents, \"session-1\", getCachedSignatureFn) as any;\n    expect(result[0].parts).toHaveLength(1);\n    expect(result[0].parts[0]).toEqual({\n      thought: true,\n      text: thinkingText,\n      thoughtSignature: validSignature,\n    });\n\n    expect(result[0].parts[0].cache_control).toBeUndefined();\n    expect(result[0].parts[0].providerOptions).toBeUndefined();\n  });\n\n  it(\"extracts wrapped thinking text and strips SDK fields for Anthropic-style thinking blocks\", () => {\n    const validSignature = \"a\".repeat(60);\n    const thinkingText = \"wrapped thinking\";\n    const getCachedSignatureFn = (_sessionId: string, text: string) =>\n      text === thinkingText ? validSignature : undefined;\n\n    const contents = [\n      {\n        role: \"model\",\n        parts: [\n          {\n            type: \"thinking\",\n            thinking: {\n              text: thinkingText,\n              cache_control: { type: \"ephemeral\" },\n              providerOptions: { injected: true },\n            },\n            signature: validSignature,\n            cache_control: { type: \"ephemeral\" },\n            providerOptions: { injected: true },\n          },\n        ],\n      },\n      { role: \"model\", parts: [{ text: \"trailing\" }] },\n    ];\n\n    const result = filterUnsignedThinkingBlocks(contents, \"session-1\", getCachedSignatureFn) as any;\n    expect(result[0].parts).toHaveLength(1);\n    expect(result[0].parts[0]).toEqual({\n      type: \"thinking\",\n      thinking: thinkingText,\n      signature: validSignature,\n    });\n  });\n\n  it(\"preserves signatures while dropping cache_control/providerOptions during signature restoration\", () => {\n    const cachedSignature = \"c\".repeat(60);\n    const getCachedSignatureFn = (_sessionId: string, _text: string) => cachedSignature;\n\n    const messages = [\n      {\n        role: \"assistant\",\n        content: [\n          {\n            type: \"thinking\",\n            thinking: {\n              thinking: \"restore me\",\n              cache_control: { type: \"ephemeral\" },\n            },\n            providerOptions: { injected: true },\n          },\n          { type: \"text\", text: \"visible\" },\n        ],\n      },\n      { role: \"user\", content: [{ type: \"text\", text: \"next\" }] },\n      { role: \"assistant\", content: [{ type: \"text\", text: \"last\" }] },\n    ];\n\n    const result = filterMessagesThinkingBlocks(messages, \"session-1\", getCachedSignatureFn) as any;\n    expect(result[0].content[0]).toEqual({\n      type: \"thinking\",\n      thinking: \"restore me\",\n      signature: cachedSignature,\n    });\n  });\n\n  it(\"sanitizes reasoning blocks keeping only allowed fields (type, text, signature)\", () => {\n    const validSignature = \"z\".repeat(60);\n    const getCachedSignatureFn = (_sessionId: string, _text: string) => validSignature;\n\n    const contents = [\n      {\n        role: \"model\",\n        parts: [\n          {\n            type: \"reasoning\",\n            text: \"reasoning text\",\n            signature: validSignature,\n            cache_control: { type: \"ephemeral\" },\n            providerOptions: { injected: true },\n            meta: { keep: true },\n          },\n          { type: \"text\", text: \"visible\" },\n        ],\n      },\n      { role: \"user\", parts: [{ text: \"next\" }] },\n      { role: \"model\", parts: [{ text: \"last\" }] },\n    ];\n\n    const result = filterUnsignedThinkingBlocks(contents, \"session-1\", getCachedSignatureFn) as any;\n    expect(result[0].parts[0]).toEqual({\n      type: \"reasoning\",\n      text: \"reasoning text\",\n      signature: validSignature,\n    });\n  });\n});\n\ndescribe(\"isThinkingCapableModel\", () => {\n  it(\"returns true for models with 'thinking' in name\", () => {\n    expect(isThinkingCapableModel(\"claude-thinking\")).toBe(true);\n    expect(isThinkingCapableModel(\"CLAUDE-THINKING-4\")).toBe(true);\n    expect(isThinkingCapableModel(\"model-thinking-v1\")).toBe(true);\n  });\n\n  it(\"returns true for models with 'gemini-3' in name\", () => {\n    expect(isThinkingCapableModel(\"gemini-3-pro\")).toBe(true);\n    expect(isThinkingCapableModel(\"GEMINI-3-flash\")).toBe(true);\n    expect(isThinkingCapableModel(\"gemini-3\")).toBe(true);\n  });\n\n  it(\"returns true for models with 'opus' in name\", () => {\n    expect(isThinkingCapableModel(\"claude-opus\")).toBe(true);\n    expect(isThinkingCapableModel(\"claude-4-opus\")).toBe(true);\n    expect(isThinkingCapableModel(\"OPUS\")).toBe(true);\n  });\n\n  it(\"returns false for non-thinking models\", () => {\n    expect(isThinkingCapableModel(\"claude-sonnet\")).toBe(false);\n    expect(isThinkingCapableModel(\"gemini-2-pro\")).toBe(false);\n    expect(isThinkingCapableModel(\"gpt-4\")).toBe(false);\n  });\n});\n\ndescribe(\"extractThinkingConfig\", () => {\n  it(\"extracts thinkingConfig from generationConfig\", () => {\n    const result = extractThinkingConfig(\n      {},\n      { thinkingConfig: { includeThoughts: true, thinkingBudget: 8000 } },\n      undefined,\n    );\n    expect(result).toEqual({ includeThoughts: true, thinkingBudget: 8000 });\n  });\n\n  it(\"extracts thinkingConfig from extra_body\", () => {\n    const result = extractThinkingConfig(\n      {},\n      undefined,\n      { thinkingConfig: { includeThoughts: true, thinkingBudget: 4000 } },\n    );\n    expect(result).toEqual({ includeThoughts: true, thinkingBudget: 4000 });\n  });\n\n  it(\"extracts thinkingConfig from requestPayload directly\", () => {\n    const result = extractThinkingConfig(\n      { thinkingConfig: { includeThoughts: false, thinkingBudget: 2000 } },\n      undefined,\n      undefined,\n    );\n    expect(result).toEqual({ includeThoughts: false, thinkingBudget: 2000 });\n  });\n\n  it(\"prioritizes generationConfig over extra_body\", () => {\n    const result = extractThinkingConfig(\n      {},\n      { thinkingConfig: { includeThoughts: true, thinkingBudget: 8000 } },\n      { thinkingConfig: { includeThoughts: false, thinkingBudget: 4000 } },\n    );\n    expect(result).toEqual({ includeThoughts: true, thinkingBudget: 8000 });\n  });\n\n  it(\"converts Anthropic-style thinking config\", () => {\n    const result = extractThinkingConfig(\n      { thinking: { type: \"enabled\", budgetTokens: 10000 } },\n      undefined,\n      undefined,\n    );\n    expect(result).toEqual({ includeThoughts: true, thinkingBudget: 10000 });\n  });\n\n  it(\"uses default budget for Anthropic-style without budgetTokens\", () => {\n    const result = extractThinkingConfig(\n      { thinking: { type: \"enabled\" } },\n      undefined,\n      undefined,\n    );\n    expect(result).toEqual({ includeThoughts: true, thinkingBudget: DEFAULT_THINKING_BUDGET });\n  });\n\n  it(\"returns undefined when no config found\", () => {\n    expect(extractThinkingConfig({}, undefined, undefined)).toBeUndefined();\n  });\n\n  it(\"uses default budget when thinkingBudget not specified\", () => {\n    const result = extractThinkingConfig(\n      {},\n      { thinkingConfig: { includeThoughts: true } },\n      undefined,\n    );\n    expect(result).toEqual({ includeThoughts: true, thinkingBudget: DEFAULT_THINKING_BUDGET });\n  });\n});\n\ndescribe(\"resolveThinkingConfig\", () => {\n  it(\"keeps thinking enabled for Claude models with assistant history\", () => {\n    const result = resolveThinkingConfig(\n      { includeThoughts: true, thinkingBudget: 8000 },\n      true, // isThinkingModel\n      true, // isClaudeModel\n      true, // hasAssistantHistory\n    );\n    expect(result).toEqual({ includeThoughts: true, thinkingBudget: 8000 });\n  });\n\n  it(\"enables thinking for thinking-capable models without user config\", () => {\n    const result = resolveThinkingConfig(\n      undefined,\n      true, // isThinkingModel\n      false, // isClaudeModel\n      false, // hasAssistantHistory\n    );\n    expect(result).toEqual({ includeThoughts: true, thinkingBudget: DEFAULT_THINKING_BUDGET });\n  });\n\n  it(\"respects user config for non-Claude models\", () => {\n    const userConfig = { includeThoughts: false, thinkingBudget: 5000 };\n    const result = resolveThinkingConfig(\n      userConfig,\n      true,\n      false,\n      false,\n    );\n    expect(result).toEqual(userConfig);\n  });\n\n  it(\"returns user config for Claude without history\", () => {\n    const userConfig = { includeThoughts: true, thinkingBudget: 8000 };\n    const result = resolveThinkingConfig(\n      userConfig,\n      true,\n      true, // isClaudeModel\n      false, // no history\n    );\n    expect(result).toEqual(userConfig);\n  });\n\n  it(\"returns undefined for non-thinking model without user config\", () => {\n    const result = resolveThinkingConfig(\n      undefined,\n      false, // not thinking model\n      false,\n      false,\n    );\n    expect(result).toBeUndefined();\n  });\n});\n\ndescribe(\"filterUnsignedThinkingBlocks\", () => {\n  it(\"filters out unsigned thinking parts\", () => {\n    const contents = [\n      {\n        role: \"model\",\n        parts: [\n          { type: \"thinking\", text: \"thinking without signature\" },\n          { type: \"text\", text: \"visible text\" },\n        ],\n      },\n      { role: \"user\", parts: [{ text: \"next\" }] },\n      { role: \"model\", parts: [{ text: \"last\" }] },\n    ];\n    const result = filterUnsignedThinkingBlocks(contents);\n    expect(result[0].parts).toHaveLength(1);\n    expect(result[0].parts[0].type).toBe(\"text\");\n  });\n\n  it(\"keeps signed thinking parts with valid signatures from our cache\", () => {\n    const validSignature = \"a\".repeat(60);\n    const thinkingText = \"thinking with signature\";\n    const getCachedSignatureFn = (_sessionId: string, text: string) =>\n      text === thinkingText ? validSignature : undefined;\n\n    const contents = [\n      {\n        role: \"model\",\n        parts: [\n          { type: \"thinking\", text: thinkingText, signature: validSignature },\n          { type: \"text\", text: \"visible text\" },\n        ],\n      },\n    ];\n    const result = filterUnsignedThinkingBlocks(contents, \"session-1\", getCachedSignatureFn);\n    expect(result[0].parts).toHaveLength(2);\n    expect(result[0].parts[0].signature).toBe(validSignature);\n  });\n\n  it(\"strips thinking parts with foreign signatures not in our cache\", () => {\n    const foreignSignature = \"f\".repeat(60);\n    const contents = [\n      {\n        role: \"model\",\n        parts: [\n          { type: \"thinking\", text: \"foreign thinking\", signature: foreignSignature },\n          { type: \"text\", text: \"visible text\" },\n        ],\n      },\n      { role: \"user\", parts: [{ text: \"next\" }] },\n      { role: \"model\", parts: [{ text: \"last\" }] },\n    ];\n    const result = filterUnsignedThinkingBlocks(contents);\n    expect(result[0].parts).toHaveLength(1);\n    expect(result[0].parts[0].type).toBe(\"text\");\n  });\n\n  it(\"filters thinking parts with short signatures\", () => {\n    const contents = [\n      {\n        role: \"model\",\n        parts: [\n          { type: \"thinking\", text: \"thinking with short signature\", signature: \"sig123\" },\n          { type: \"text\", text: \"visible text\" },\n        ],\n      },\n      { role: \"user\", parts: [{ text: \"next\" }] },\n      { role: \"model\", parts: [{ text: \"last\" }] },\n    ];\n    const result = filterUnsignedThinkingBlocks(contents);\n    expect(result[0].parts).toHaveLength(1);\n    expect(result[0].parts[0].type).toBe(\"text\");\n  });\n\n  it(\"handles Gemini-style thought parts with valid signatures from our cache\", () => {\n    const validSignature = \"b\".repeat(55);\n    const thinkingText = \"has signature\";\n    const getCachedSignatureFn = (_sessionId: string, text: string) =>\n      text === thinkingText ? validSignature : undefined;\n\n    const contents = [\n      {\n        role: \"model\",\n        parts: [\n          { thought: true, text: \"no signature\" },\n          { thought: true, text: thinkingText, thoughtSignature: validSignature },\n        ],\n      },\n      { role: \"user\", parts: [{ text: \"next\" }] },\n      { role: \"model\", parts: [{ text: \"last\" }] },\n    ];\n    const result = filterUnsignedThinkingBlocks(contents, \"session-1\", getCachedSignatureFn);\n    expect(result[0].parts).toHaveLength(1);\n    expect(result[0].parts[0].thoughtSignature).toBe(validSignature);\n  });\n\n  it(\"filters Gemini-style thought parts with short signatures\", () => {\n    const contents = [\n      {\n        role: \"model\",\n        parts: [\n          { thought: true, text: \"has short signature\", thoughtSignature: \"sig\" },\n        ],\n      },\n    ];\n    const result = filterUnsignedThinkingBlocks(contents);\n    expect(result[0].parts).toHaveLength(0);\n  });\n\n  it(\"preserves non-thinking parts\", () => {\n    const contents = [\n      {\n        role: \"user\",\n        parts: [{ text: \"hello\" }],\n      },\n    ];\n    const result = filterUnsignedThinkingBlocks(contents);\n    expect(result).toEqual(contents);\n  });\n\n  it(\"strips blocks with signature field even if type is unknown\", () => {\n    const foreignSignature = \"x\".repeat(60);\n    const contents = [\n      {\n        role: \"model\",\n        parts: [\n          { type: \"unknown_thinking_type\", text: \"foreign block\", signature: foreignSignature },\n          { type: \"text\", text: \"visible\" },\n        ],\n      },\n      { role: \"user\", parts: [{ text: \"next\" }] },\n      { role: \"model\", parts: [{ text: \"last\" }] },\n    ];\n    const result = filterUnsignedThinkingBlocks(contents);\n    expect(result[0].parts).toHaveLength(1);\n    expect(result[0].parts[0].type).toBe(\"text\");\n  });\n\n  it(\"handles empty parts array\", () => {\n    const contents = [{ role: \"model\", parts: [] }];\n    const result = filterUnsignedThinkingBlocks(contents);\n    expect(result[0].parts).toEqual([]);\n  });\n\n  it(\"handles missing parts\", () => {\n    const contents = [{ role: \"model\" }];\n    const result = filterUnsignedThinkingBlocks(contents);\n    expect(result).toEqual(contents);\n  });\n\n  it(\"preserves tool_use and tool_result blocks intact\", () => {\n    const contents = [\n      {\n        role: \"model\",\n        parts: [\n          { type: \"tool_use\", id: \"tool_123\", name: \"bash\", input: { command: \"ls\" } },\n        ],\n      },\n      {\n        role: \"user\",\n        parts: [\n          { type: \"tool_result\", tool_use_id: \"tool_123\", content: \"file1.txt\" },\n        ],\n      },\n    ];\n    const result = filterUnsignedThinkingBlocks(contents);\n    expect(result[0].parts[0]).toEqual({ type: \"tool_use\", id: \"tool_123\", name: \"bash\", input: { command: \"ls\" } });\n    expect(result[1].parts[0]).toEqual({ type: \"tool_result\", tool_use_id: \"tool_123\", content: \"file1.txt\" });\n  });\n\n  it(\"preserves tool blocks even if they have signature-like fields\", () => {\n    const contents = [\n      {\n        role: \"user\",\n        parts: [\n          { type: \"tool_result\", tool_use_id: \"tool_456\", content: \"result\", signature: \"some_random_value\" },\n        ],\n      },\n    ];\n    const result = filterUnsignedThinkingBlocks(contents);\n    expect(result[0].parts).toHaveLength(1);\n    expect(result[0].parts[0].tool_use_id).toBe(\"tool_456\");\n  });\n\n  it(\"preserves nested tool_result format\", () => {\n    const contents = [\n      {\n        role: \"user\",\n        parts: [\n          { tool_result: { tool_use_id: \"tool_789\", content: \"nested result\" } },\n        ],\n      },\n    ];\n    const result = filterUnsignedThinkingBlocks(contents);\n    expect(result[0].parts).toHaveLength(1);\n    expect(result[0].parts[0].tool_result.tool_use_id).toBe(\"tool_789\");\n  });\n\n  it(\"preserves functionCall and functionResponse blocks\", () => {\n    const contents = [\n      {\n        role: \"model\",\n        parts: [\n          { functionCall: { name: \"get_weather\", args: { city: \"NYC\" } } },\n        ],\n      },\n      {\n        role: \"function\",\n        parts: [\n          { functionResponse: { name: \"get_weather\", response: { temp: 72 } } },\n        ],\n      },\n    ];\n    const result = filterUnsignedThinkingBlocks(contents);\n    expect(result[0].parts[0].functionCall).toBeDefined();\n    expect(result[1].parts[0].functionResponse).toBeDefined();\n  });\n});\n\ndescribe(\"deepFilterThinkingBlocks\", () => {\n  it(\"removes nested thinking blocks in extra_body messages\", () => {\n    const payload = {\n      extra_body: {\n        messages: [\n          {\n            role: \"assistant\",\n            content: [\n              { type: \"thinking\", thinking: \"foreign\", signature: \"x\".repeat(60) },\n              { type: \"text\", text: \"visible\" },\n            ],\n          },\n          { role: \"assistant\", content: [{ type: \"text\", text: \"last\" }] },\n        ],\n      },\n    };\n\n    deepFilterThinkingBlocks(payload);\n    const filtered = (payload as any).extra_body.messages[0].content;\n    expect(filtered).toHaveLength(1);\n    expect(filtered[0].type).toBe(\"text\");\n  });\n\n});\n\ndescribe(\"filterMessagesThinkingBlocks\", () => {\n  it(\"filters out unsigned thinking blocks in messages[].content\", () => {\n    const messages = [\n      {\n        role: \"assistant\",\n        content: [\n          { type: \"thinking\", thinking: \"no signature\" },\n          { type: \"text\", text: \"visible\" },\n        ],\n      },\n      { role: \"assistant\", content: [{ type: \"text\", text: \"last\" }] },\n    ];\n\n    const result = filterMessagesThinkingBlocks(messages) as any;\n    expect(result[0].content).toHaveLength(1);\n    expect(result[0].content[0].type).toBe(\"text\");\n  });\n\n  it(\"keeps signed thinking blocks with valid signatures from our cache and sanitizes injected fields\", () => {\n    const validSignature = \"a\".repeat(60);\n    const thinkingText = \"wrapped\";\n    const getCachedSignatureFn = (_sessionId: string, text: string) =>\n      text === thinkingText ? validSignature : undefined;\n\n    const messages = [\n      {\n        role: \"assistant\",\n        content: [\n          {\n            type: \"thinking\",\n            thinking: { text: thinkingText, cache_control: { type: \"ephemeral\" } },\n            signature: validSignature,\n            cache_control: { type: \"ephemeral\" },\n            providerOptions: { injected: true },\n          },\n          { type: \"text\", text: \"visible\" },\n        ],\n      },\n      { role: \"assistant\", content: [{ type: \"text\", text: \"last\" }] },\n    ];\n\n    const result = filterMessagesThinkingBlocks(messages, \"session-1\", getCachedSignatureFn) as any;\n    expect(result[0].content[0]).toEqual({\n      type: \"thinking\",\n      thinking: thinkingText,\n      signature: validSignature,\n    });\n  });\n\n  it(\"strips thinking blocks with foreign signatures not in our cache\", () => {\n    const foreignSignature = \"f\".repeat(60);\n    const messages = [\n      {\n        role: \"assistant\",\n        content: [\n          {\n            type: \"thinking\",\n            thinking: \"foreign thinking\",\n            signature: foreignSignature,\n          },\n          { type: \"text\", text: \"visible\" },\n        ],\n      },\n      { role: \"assistant\", content: [{ type: \"text\", text: \"last\" }] },\n    ];\n\n    const result = filterMessagesThinkingBlocks(messages) as any;\n    expect(result[0].content).toHaveLength(1);\n    expect(result[0].content[0].type).toBe(\"text\");\n  });\n\n  it(\"filters thinking blocks with short signatures\", () => {\n    const messages = [\n      {\n        role: \"assistant\",\n        content: [\n          { type: \"thinking\", thinking: \"short sig\", signature: \"sig123\" },\n          { type: \"text\", text: \"visible\" },\n        ],\n      },\n      { role: \"assistant\", content: [{ type: \"text\", text: \"last\" }] },\n    ];\n\n    const result = filterMessagesThinkingBlocks(messages) as any;\n    expect(result[0].content).toEqual([{ type: \"text\", text: \"visible\" }]);\n  });\n\n  it(\"restores a missing signature from cache and preserves it after sanitization\", () => {\n    const cachedSignature = \"c\".repeat(60);\n    const getCachedSignatureFn = (_sessionId: string, _text: string) => cachedSignature;\n\n    const messages = [\n      {\n        role: \"assistant\",\n        content: [\n          {\n            type: \"thinking\",\n            thinking: { thinking: \"restore me\", providerOptions: { injected: true } },\n            // no signature present (forces restore)\n            cache_control: { type: \"ephemeral\" },\n          },\n          { type: \"text\", text: \"visible\" },\n        ],\n      },\n      { role: \"assistant\", content: [{ type: \"text\", text: \"last\" }] },\n    ];\n\n    const result = filterMessagesThinkingBlocks(messages, \"session-1\", getCachedSignatureFn) as any;\n    expect(result[0].content[0]).toEqual({\n      type: \"thinking\",\n      thinking: \"restore me\",\n      signature: cachedSignature,\n    });\n  });\n\n  it(\"handles Gemini-style thought blocks inside messages content with cached signatures\", () => {\n    const validSignature = \"b\".repeat(60);\n    const thinkingText = \"wrapped thought\";\n    const getCachedSignatureFn = (_sessionId: string, text: string) =>\n      text === thinkingText ? validSignature : undefined;\n\n    const messages = [\n      {\n        role: \"assistant\",\n        content: [\n          {\n            thought: true,\n            text: { text: thinkingText, cache_control: { type: \"ephemeral\" } },\n            thoughtSignature: validSignature,\n            providerOptions: { injected: true },\n          },\n          { type: \"text\", text: \"visible\" },\n        ],\n      },\n      { role: \"assistant\", content: [{ type: \"text\", text: \"last\" }] },\n    ];\n\n    const result = filterMessagesThinkingBlocks(messages, \"session-1\", getCachedSignatureFn) as any;\n    expect(result[0].content[0]).toEqual({\n      thought: true,\n      text: thinkingText,\n      thoughtSignature: validSignature,\n    });\n  });\n\n  it(\"preserves non-thinking blocks and returns message unchanged when content is missing\", () => {\n    const messages: any[] = [\n      { role: \"assistant\", content: [{ type: \"text\", text: \"hello\" }] },\n      { role: \"assistant\" },\n    ];\n\n    const result = filterMessagesThinkingBlocks(messages) as any;\n    expect(result[0]).toEqual(messages[0]);\n    expect(result[1]).toEqual(messages[1]);\n  });\n\n  it(\"handles non-object messages gracefully\", () => {\n    const messages: any[] = [null, \"string\", 123, { role: \"assistant\", content: [] }];\n    const result = filterMessagesThinkingBlocks(messages) as any;\n    expect(result).toEqual(messages);\n  });\n});\n\ndescribe(\"transformThinkingParts\", () => {\n  it(\"transforms Anthropic-style thinking blocks to reasoning\", () => {\n    const response = {\n      content: [\n        { type: \"thinking\", thinking: \"my thoughts\" },\n        { type: \"text\", text: \"visible\" },\n      ],\n    };\n    const result = transformThinkingParts(response) as any;\n    expect(result.content[0].type).toBe(\"reasoning\");\n    expect(result.content[0].thought).toBe(true);\n    expect(result.reasoning_content).toBe(\"my thoughts\");\n  });\n\n  it(\"transforms Gemini-style candidates\", () => {\n    const response = {\n      candidates: [\n        {\n          content: {\n            parts: [\n              { thought: true, text: \"thinking here\" },\n              { text: \"output\" },\n            ],\n          },\n        },\n      ],\n    };\n    const result = transformThinkingParts(response) as any;\n    expect(result.candidates[0].content.parts[0].type).toBe(\"reasoning\");\n    expect(result.candidates[0].reasoning_content).toBe(\"thinking here\");\n  });\n\n  it(\"handles non-object input\", () => {\n    expect(transformThinkingParts(null)).toBeNull();\n    expect(transformThinkingParts(undefined)).toBeUndefined();\n    expect(transformThinkingParts(\"string\")).toBe(\"string\");\n  });\n\n  it(\"preserves other response properties\", () => {\n    const response = {\n      content: [],\n      id: \"resp-123\",\n      model: \"claude-4\",\n    };\n    const result = transformThinkingParts(response) as any;\n    expect(result.id).toBe(\"resp-123\");\n    expect(result.model).toBe(\"claude-4\");\n  });\n\n  it(\"converts Gemini-style thoughtSignature to providerMetadata.anthropic.signature\", () => {\n    const response = {\n      candidates: [\n        {\n          content: {\n            parts: [\n              { thought: true, text: \"thinking here\", thoughtSignature: \"sig123abc\" },\n              { text: \"output\" },\n            ],\n          },\n        },\n      ],\n    };\n    const result = transformThinkingParts(response) as any;\n    expect(result.candidates[0].content.parts[0].providerMetadata).toEqual({\n      anthropic: { signature: \"sig123abc\" }\n    });\n    expect(result.candidates[0].content.parts[0].thoughtSignature).toBeUndefined();\n  });\n\n  it(\"converts Anthropic-style signature to providerMetadata.anthropic.signature\", () => {\n    const response = {\n      candidates: [\n        {\n          content: {\n            parts: [\n              { type: \"thinking\", text: \"thinking here\", signature: \"anthro_sig_xyz\" },\n              { text: \"output\" },\n            ],\n          },\n        },\n      ],\n    };\n    const result = transformThinkingParts(response) as any;\n    expect(result.candidates[0].content.parts[0].providerMetadata).toEqual({\n      anthropic: { signature: \"anthro_sig_xyz\" }\n    });\n    expect(result.candidates[0].content.parts[0].signature).toBeUndefined();\n  });\n\n  it(\"converts signature in content array (Anthropic-style)\", () => {\n    const response = {\n      content: [\n        { type: \"thinking\", thinking: \"my thoughts\", signature: \"content_sig\" },\n        { type: \"text\", text: \"visible\" },\n      ],\n    };\n    const result = transformThinkingParts(response) as any;\n    expect(result.content[0].providerMetadata).toEqual({\n      anthropic: { signature: \"content_sig\" }\n    });\n    expect(result.content[0].signature).toBeUndefined();\n    expect(result.content[0].thoughtSignature).toBeUndefined();\n  });\n\n  it(\"prefers signature over thoughtSignature when both present\", () => {\n    const response = {\n      candidates: [\n        {\n          content: {\n            parts: [\n              { thought: true, text: \"thinking\", signature: \"sig_primary\", thoughtSignature: \"sig_fallback\" },\n            ],\n          },\n        },\n      ],\n    };\n    const result = transformThinkingParts(response) as any;\n    expect(result.candidates[0].content.parts[0].providerMetadata).toEqual({\n      anthropic: { signature: \"sig_primary\" }\n    });\n  });\n\n  it(\"does not add providerMetadata when no signature present\", () => {\n    const response = {\n      candidates: [\n        {\n          content: {\n            parts: [\n              { thought: true, text: \"thinking without signature\" },\n              { text: \"output\" },\n            ],\n          },\n        },\n      ],\n    };\n    const result = transformThinkingParts(response) as any;\n    expect(result.candidates[0].content.parts[0].providerMetadata).toBeUndefined();\n  });\n});\n\ndescribe(\"normalizeThinkingConfig\", () => {\n  it(\"returns undefined for non-object input\", () => {\n    expect(normalizeThinkingConfig(null)).toBeUndefined();\n    expect(normalizeThinkingConfig(undefined)).toBeUndefined();\n    expect(normalizeThinkingConfig(\"string\")).toBeUndefined();\n  });\n\n  it(\"normalizes valid config\", () => {\n    const result = normalizeThinkingConfig({\n      thinkingBudget: 8000,\n      includeThoughts: true,\n    });\n    expect(result).toEqual({\n      thinkingBudget: 8000,\n      includeThoughts: true,\n    });\n  });\n\n  it(\"handles snake_case property names\", () => {\n    const result = normalizeThinkingConfig({\n      thinking_budget: 4000,\n      include_thoughts: true,\n    });\n    expect(result).toEqual({\n      thinkingBudget: 4000,\n      includeThoughts: true,\n    });\n  });\n\n  it(\"disables includeThoughts when budget is 0\", () => {\n    const result = normalizeThinkingConfig({\n      thinkingBudget: 0,\n      includeThoughts: true,\n    });\n    expect(result?.includeThoughts).toBe(false);\n  });\n\n  it(\"returns undefined when both values are absent/undefined\", () => {\n    const result = normalizeThinkingConfig({});\n    expect(result).toBeUndefined();\n  });\n\n  it(\"handles non-finite budget values\", () => {\n    const result = normalizeThinkingConfig({\n      thinkingBudget: Infinity,\n      includeThoughts: true,\n    });\n    // When budget is non-finite (undefined), includeThoughts is forced to false\n    expect(result).toEqual({ includeThoughts: false });\n  });\n});\n\ndescribe(\"parseAntigravityApiBody\", () => {\n  it(\"parses valid JSON object\", () => {\n    const result = parseAntigravityApiBody('{\"response\": {\"text\": \"hello\"}}');\n    expect(result).toEqual({ response: { text: \"hello\" } });\n  });\n\n  it(\"extracts first object from array\", () => {\n    const result = parseAntigravityApiBody('[{\"response\": \"first\"}, {\"response\": \"second\"}]');\n    expect(result).toEqual({ response: \"first\" });\n  });\n\n  it(\"returns null for invalid JSON\", () => {\n    expect(parseAntigravityApiBody(\"not json\")).toBeNull();\n  });\n\n  it(\"returns null for empty array\", () => {\n    expect(parseAntigravityApiBody(\"[]\")).toBeNull();\n  });\n\n  it(\"returns null for primitive values\", () => {\n    expect(parseAntigravityApiBody('\"string\"')).toBeNull();\n    expect(parseAntigravityApiBody(\"123\")).toBeNull();\n  });\n\n  it(\"handles array with null values\", () => {\n    const result = parseAntigravityApiBody('[null, {\"valid\": true}]');\n    expect(result).toEqual({ valid: true });\n  });\n});\n\ndescribe(\"extractUsageMetadata\", () => {\n  it(\"extracts usage from response.usageMetadata\", () => {\n    const body = {\n      response: {\n        usageMetadata: {\n          totalTokenCount: 1000,\n          promptTokenCount: 500,\n          candidatesTokenCount: 500,\n          cachedContentTokenCount: 100,\n        },\n      },\n    };\n    const result = extractUsageMetadata(body);\n    expect(result).toEqual({\n      totalTokenCount: 1000,\n      promptTokenCount: 500,\n      candidatesTokenCount: 500,\n      cachedContentTokenCount: 100,\n    });\n  });\n\n  it(\"returns null when no usageMetadata\", () => {\n    expect(extractUsageMetadata({ response: {} })).toBeNull();\n    expect(extractUsageMetadata({})).toBeNull();\n  });\n\n  it(\"handles partial usage data\", () => {\n    const body = {\n      response: {\n        usageMetadata: {\n          totalTokenCount: 1000,\n        },\n      },\n    };\n    const result = extractUsageMetadata(body);\n    expect(result).toEqual({\n      totalTokenCount: 1000,\n      promptTokenCount: undefined,\n      candidatesTokenCount: undefined,\n      cachedContentTokenCount: undefined,\n    });\n  });\n\n  it(\"filters non-finite numbers\", () => {\n    const body = {\n      response: {\n        usageMetadata: {\n          totalTokenCount: Infinity,\n          promptTokenCount: NaN,\n          candidatesTokenCount: 100,\n        },\n      },\n    };\n    const result = extractUsageMetadata(body);\n    expect(result?.totalTokenCount).toBeUndefined();\n    expect(result?.promptTokenCount).toBeUndefined();\n    expect(result?.candidatesTokenCount).toBe(100);\n  });\n});\n\ndescribe(\"extractUsageFromSsePayload\", () => {\n  it(\"extracts usage from SSE data line\", () => {\n    const payload = `data: {\"response\": {\"usageMetadata\": {\"totalTokenCount\": 500}}}`;\n    const result = extractUsageFromSsePayload(payload);\n    expect(result?.totalTokenCount).toBe(500);\n  });\n\n  it(\"handles multiple SSE lines\", () => {\n    const payload = `data: {\"response\": {}}\ndata: {\"response\": {\"usageMetadata\": {\"totalTokenCount\": 1000}}}`;\n    const result = extractUsageFromSsePayload(payload);\n    expect(result?.totalTokenCount).toBe(1000);\n  });\n\n  it(\"returns null when no usage found\", () => {\n    const payload = `data: {\"response\": {\"text\": \"hello\"}}`;\n    const result = extractUsageFromSsePayload(payload);\n    expect(result).toBeNull();\n  });\n\n  it(\"ignores non-data lines\", () => {\n    const payload = `: keepalive\nevent: message\ndata: {\"response\": {\"usageMetadata\": {\"totalTokenCount\": 200}}}`;\n    const result = extractUsageFromSsePayload(payload);\n    expect(result?.totalTokenCount).toBe(200);\n  });\n\n  it(\"handles malformed JSON gracefully\", () => {\n    const payload = `data: not json\ndata: {\"response\": {\"usageMetadata\": {\"totalTokenCount\": 300}}}`;\n    const result = extractUsageFromSsePayload(payload);\n    expect(result?.totalTokenCount).toBe(300);\n  });\n});\n\ndescribe(\"rewriteAntigravityPreviewAccessError\", () => {\n  it(\"returns null for non-404 status\", () => {\n    const body = { error: { message: \"Not found\" } };\n    expect(rewriteAntigravityPreviewAccessError(body, 400)).toBeNull();\n    expect(rewriteAntigravityPreviewAccessError(body, 500)).toBeNull();\n  });\n\n  it(\"rewrites error for Antigravity model on 404\", () => {\n    const body = { error: { message: \"Model not found\" } };\n    const result = rewriteAntigravityPreviewAccessError(body, 404, \"claude-opus\");\n    expect(result?.error?.message).toContain(\"Model not found\");\n    expect(result?.error?.message).toContain(\"preview access\");\n  });\n\n  it(\"rewrites error when error message contains antigravity\", () => {\n    const body = { error: { message: \"antigravity model unavailable\" } };\n    const result = rewriteAntigravityPreviewAccessError(body, 404);\n    expect(result?.error?.message).toContain(\"preview access\");\n  });\n\n  it(\"returns null for 404 with non-antigravity model\", () => {\n    const body = { error: { message: \"Model not found\" } };\n    const result = rewriteAntigravityPreviewAccessError(body, 404, \"gemini-pro\");\n    expect(result).toBeNull();\n  });\n\n  it(\"provides default message when error message is empty\", () => {\n    const body = { error: { message: \"\" } };\n    const result = rewriteAntigravityPreviewAccessError(body, 404, \"opus-model\");\n    expect(result?.error?.message).toContain(\"Antigravity preview features are not enabled\");\n  });\n\n  it(\"detects Claude models in requested model name\", () => {\n    const body = { error: {} };\n    const result = rewriteAntigravityPreviewAccessError(body, 404, \"claude-3-sonnet\");\n    expect(result?.error?.message).toContain(\"preview access\");\n  });\n});\n\ndescribe(\"findOrphanedToolUseIds\", () => {\n  it(\"returns empty set when no tool_use blocks\", () => {\n    const messages = [\n      { role: \"user\", content: \"Hello\" },\n      { role: \"assistant\", content: \"Hi there!\" },\n    ];\n    const result = findOrphanedToolUseIds(messages);\n    expect(result.size).toBe(0);\n  });\n\n  it(\"returns empty set when all tool_use have matching tool_result\", () => {\n    const messages = [\n      {\n        role: \"assistant\",\n        content: [{ type: \"tool_use\", id: \"tool-1\", name: \"read\", input: {} }],\n      },\n      {\n        role: \"user\",\n        content: [{ type: \"tool_result\", tool_use_id: \"tool-1\", content: \"ok\" }],\n      },\n    ];\n    const result = findOrphanedToolUseIds(messages);\n    expect(result.size).toBe(0);\n  });\n\n  it(\"finds orphaned tool_use without matching tool_result\", () => {\n    const messages = [\n      {\n        role: \"assistant\",\n        content: [\n          { type: \"tool_use\", id: \"tool-1\", name: \"read\", input: {} },\n          { type: \"tool_use\", id: \"tool-2\", name: \"bash\", input: {} },\n        ],\n      },\n      {\n        role: \"user\",\n        content: [{ type: \"tool_result\", tool_use_id: \"tool-1\", content: \"ok\" }],\n      },\n    ];\n    const result = findOrphanedToolUseIds(messages);\n    expect(result.size).toBe(1);\n    expect(result.has(\"tool-2\")).toBe(true);\n  });\n});\n\ndescribe(\"fixClaudeToolPairing\", () => {\n  it(\"does not modify messages without tool_use\", () => {\n    const messages = [\n      { role: \"user\", content: \"Hello\" },\n      { role: \"assistant\", content: \"Hi there!\" },\n    ];\n    const result = fixClaudeToolPairing(messages);\n    expect(result).toEqual(messages);\n  });\n\n  it(\"does not modify properly paired tool calls\", () => {\n    const messages = [\n      { role: \"user\", content: \"Check file\" },\n      {\n        role: \"assistant\",\n        content: [\n          { type: \"text\", text: \"Let me check...\" },\n          { type: \"tool_use\", id: \"tool-1\", name: \"read\", input: { path: \"/foo\" } },\n        ],\n      },\n      {\n        role: \"user\",\n        content: [{ type: \"tool_result\", tool_use_id: \"tool-1\", content: \"file contents\" }],\n      },\n    ];\n    const result = fixClaudeToolPairing(messages);\n    expect(result).toEqual(messages);\n  });\n\n  it(\"injects placeholder for single orphaned tool_use\", () => {\n    const messages = [\n      { role: \"user\", content: \"Check file\" },\n      {\n        role: \"assistant\",\n        content: [{ type: \"tool_use\", id: \"tool-1\", name: \"read\", input: {} }],\n      },\n      { role: \"user\", content: [{ type: \"text\", text: \"continue\" }] },\n    ];\n\n    const result = fixClaudeToolPairing(messages);\n\n    expect(result.length).toBe(3);\n    expect(result[2].content[0].type).toBe(\"tool_result\");\n    expect(result[2].content[0].tool_use_id).toBe(\"tool-1\");\n    expect(result[2].content[0].is_error).toBe(true);\n    expect(result[2].content[1].type).toBe(\"text\");\n  });\n\n  it(\"handles multiple orphaned tools in same message\", () => {\n    const messages = [\n      {\n        role: \"assistant\",\n        content: [\n          { type: \"tool_use\", id: \"tool-1\", name: \"read\", input: {} },\n          { type: \"tool_use\", id: \"tool-2\", name: \"bash\", input: {} },\n        ],\n      },\n      { role: \"user\", content: [{ type: \"text\", text: \"continue\" }] },\n    ];\n\n    const result = fixClaudeToolPairing(messages);\n\n    expect(result[1].content.length).toBe(3);\n    expect(result[1].content[0].tool_use_id).toBe(\"tool-1\");\n    expect(result[1].content[1].tool_use_id).toBe(\"tool-2\");\n    expect(result[1].content[2].type).toBe(\"text\");\n  });\n\n  it(\"handles empty messages array\", () => {\n    expect(fixClaudeToolPairing([])).toEqual([]);\n  });\n\n  it(\"handles non-array input\", () => {\n    expect(fixClaudeToolPairing(null as any)).toEqual(null);\n    expect(fixClaudeToolPairing(undefined as any)).toEqual(undefined);\n  });\n});\n\ndescribe(\"validateAndFixClaudeToolPairing\", () => {\n  it(\"returns messages unchanged when no orphans\", () => {\n    const messages = [\n      { role: \"user\", content: \"Hello\" },\n      { role: \"assistant\", content: \"Hi!\" },\n    ];\n    const result = validateAndFixClaudeToolPairing(messages);\n    expect(result).toEqual(messages);\n  });\n\n  it(\"fixes orphaned tool_use with placeholder\", () => {\n    const messages = [\n      {\n        role: \"assistant\",\n        content: [{ type: \"tool_use\", id: \"tool-1\", name: \"bash\", input: {} }],\n      },\n      { role: \"user\", content: [{ type: \"text\", text: \"skip that\" }] },\n    ];\n\n    const result = validateAndFixClaudeToolPairing(messages);\n    const orphans = findOrphanedToolUseIds(result);\n    expect(orphans.size).toBe(0);\n  });\n\n  it(\"handles empty array\", () => {\n    expect(validateAndFixClaudeToolPairing([])).toEqual([]);\n  });\n});\n\ndescribe(\"injectParameterSignatures\", () => {\n  it(\"injects signatures into tool descriptions\", () => {\n    const tools = [\n      {\n        functionDeclarations: [\n          {\n            name: \"read\",\n            description: \"Read a file\",\n            parameters: {\n              type: \"object\",\n              properties: {\n                path: { type: \"string\", description: \"File path\" },\n              },\n              required: [\"path\"],\n            },\n          },\n        ],\n      },\n    ];\n\n    const result = injectParameterSignatures(tools);\n    expect(result[0].functionDeclarations[0].description).toContain(\"STRICT PARAMETERS:\");\n    expect(result[0].functionDeclarations[0].description).toContain(\"path\");\n    expect(result[0].functionDeclarations[0].description).toContain(\"REQUIRED\");\n  });\n\n  it(\"skips injection if STRICT PARAMETERS already present\", () => {\n    const tools = [\n      {\n        functionDeclarations: [\n          {\n            name: \"read\",\n            description: \"Read a file\\n\\nSTRICT PARAMETERS: path (string, REQUIRED)\",\n            parameters: {\n              type: \"object\",\n              properties: {\n                path: { type: \"string\" },\n              },\n              required: [\"path\"],\n            },\n          },\n        ],\n      },\n    ];\n\n    const result = injectParameterSignatures(tools);\n    const matches = result[0].functionDeclarations[0].description.match(/STRICT PARAMETERS/g);\n    expect(matches).toHaveLength(1);\n  });\n\n  it(\"skips tools without properties\", () => {\n    const tools = [\n      {\n        functionDeclarations: [\n          {\n            name: \"empty_tool\",\n            description: \"A tool with no params\",\n            parameters: {\n              type: \"object\",\n              properties: {},\n            },\n          },\n        ],\n      },\n    ];\n\n    const result = injectParameterSignatures(tools);\n    expect(result[0].functionDeclarations[0].description).toBe(\"A tool with no params\");\n  });\n\n  it(\"handles missing parameters gracefully\", () => {\n    const tools = [\n      {\n        functionDeclarations: [\n          {\n            name: \"no_params\",\n            description: \"No parameters defined\",\n          },\n        ],\n      },\n    ];\n\n    const result = injectParameterSignatures(tools);\n    expect(result[0].functionDeclarations[0].description).toBe(\"No parameters defined\");\n  });\n\n  it(\"returns empty array for empty input\", () => {\n    expect(injectParameterSignatures([])).toEqual([]);\n  });\n\n  it(\"returns null/undefined as-is\", () => {\n    expect(injectParameterSignatures(null as any)).toBeNull();\n    expect(injectParameterSignatures(undefined as any)).toBeUndefined();\n  });\n});\n\ndescribe(\"injectToolHardeningInstruction\", () => {\n  it(\"injects system instruction when none exists\", () => {\n    const payload: Record<string, unknown> = {};\n    injectToolHardeningInstruction(payload, \"CRITICAL TOOL USAGE INSTRUCTIONS: Test\");\n    \n    expect(payload.systemInstruction).toBeDefined();\n    const instruction = payload.systemInstruction as any;\n    expect(instruction.parts[0].text).toBe(\"CRITICAL TOOL USAGE INSTRUCTIONS: Test\");\n  });\n\n  it(\"prepends to existing system instruction parts\", () => {\n    const payload: Record<string, unknown> = {\n      systemInstruction: {\n        parts: [{ text: \"Existing instruction\" }],\n      },\n    };\n    injectToolHardeningInstruction(payload, \"CRITICAL TOOL USAGE INSTRUCTIONS: New\");\n    \n    const instruction = payload.systemInstruction as any;\n    expect(instruction.parts).toHaveLength(2);\n    expect(instruction.parts[0].text).toBe(\"CRITICAL TOOL USAGE INSTRUCTIONS: New\");\n    expect(instruction.parts[1].text).toBe(\"Existing instruction\");\n  });\n\n  it(\"skips injection if CRITICAL TOOL USAGE INSTRUCTIONS already present\", () => {\n    const payload: Record<string, unknown> = {\n      systemInstruction: {\n        parts: [{ text: \"CRITICAL TOOL USAGE INSTRUCTIONS: Already here\" }],\n      },\n    };\n    injectToolHardeningInstruction(payload, \"CRITICAL TOOL USAGE INSTRUCTIONS: New\");\n    \n    const instruction = payload.systemInstruction as any;\n    expect(instruction.parts).toHaveLength(1);\n    expect(instruction.parts[0].text).toBe(\"CRITICAL TOOL USAGE INSTRUCTIONS: Already here\");\n  });\n\n  it(\"handles string systemInstruction\", () => {\n    const payload: Record<string, unknown> = {\n      systemInstruction: \"Existing string instruction\",\n    };\n    injectToolHardeningInstruction(payload, \"CRITICAL TOOL USAGE INSTRUCTIONS: Test\");\n    \n    const instruction = payload.systemInstruction as any;\n    expect(instruction.parts).toHaveLength(2);\n    expect(instruction.parts[0].text).toBe(\"CRITICAL TOOL USAGE INSTRUCTIONS: Test\");\n    expect(instruction.parts[1].text).toBe(\"Existing string instruction\");\n  });\n\n  it(\"does nothing when instructionText is empty\", () => {\n    const payload: Record<string, unknown> = {};\n    injectToolHardeningInstruction(payload, \"\");\n    expect(payload.systemInstruction).toBeUndefined();\n  });\n});\n\ndescribe(\"placeholder parameter for empty schemas\", () => {\n  it(\"uses _placeholder boolean instead of reason string\", () => {\n    const tools = [\n      {\n        functionDeclarations: [\n          {\n            name: \"todoread\",\n            description: \"Read todo list\",\n            parameters: {\n              type: \"object\",\n              properties: {\n                _placeholder: { type: \"boolean\", description: \"Placeholder. Always pass true.\" },\n              },\n              required: [\"_placeholder\"],\n            },\n          },\n        ],\n      },\n    ];\n\n    const result = injectParameterSignatures(tools);\n    expect(result[0].functionDeclarations[0].description).toContain(\"STRICT PARAMETERS:\");\n    expect(result[0].functionDeclarations[0].description).toContain(\"_placeholder (boolean\");\n  });\n});\n\ndescribe(\"cleanJSONSchemaForAntigravity\", () => {\n  describe(\"enum merging from anyOf/oneOf\", () => {\n    it(\"merges anyOf with const values into enum (WebFetch format pattern)\", () => {\n      const schema = {\n        type: \"object\",\n        properties: {\n          format: {\n            anyOf: [\n              { const: \"text\" },\n              { const: \"markdown\" },\n              { const: \"html\" },\n            ],\n          },\n        },\n      };\n\n      const result = cleanJSONSchemaForAntigravity(schema);\n\n      expect(result.properties.format.enum).toEqual([\"text\", \"markdown\", \"html\"]);\n      expect(result.properties.format.anyOf).toBeUndefined();\n      expect(result.properties.format.type).toBe(\"string\");\n    });\n\n    it(\"merges oneOf with const values into enum\", () => {\n      const schema = {\n        type: \"object\",\n        properties: {\n          status: {\n            oneOf: [\n              { const: \"pending\" },\n              { const: \"active\" },\n              { const: \"completed\" },\n            ],\n          },\n        },\n      };\n\n      const result = cleanJSONSchemaForAntigravity(schema);\n\n      expect(result.properties.status.enum).toEqual([\"pending\", \"active\", \"completed\"]);\n      expect(result.properties.status.oneOf).toBeUndefined();\n    });\n\n    it(\"merges anyOf with single-value enums into combined enum\", () => {\n      const schema = {\n        type: \"object\",\n        properties: {\n          level: {\n            anyOf: [\n              { enum: [\"low\"] },\n              { enum: [\"medium\"] },\n              { enum: [\"high\"] },\n            ],\n          },\n        },\n      };\n\n      const result = cleanJSONSchemaForAntigravity(schema);\n\n      expect(result.properties.level.enum).toEqual([\"low\", \"medium\", \"high\"]);\n    });\n\n    it(\"merges anyOf with multi-value enums\", () => {\n      const schema = {\n        type: \"object\",\n        properties: {\n          color: {\n            anyOf: [\n              { enum: [\"red\", \"blue\"] },\n              { enum: [\"green\", \"yellow\"] },\n            ],\n          },\n        },\n      };\n\n      const result = cleanJSONSchemaForAntigravity(schema);\n\n      expect(result.properties.color.enum).toEqual([\"red\", \"blue\", \"green\", \"yellow\"]);\n    });\n\n    it(\"does not merge anyOf with complex types (not enum pattern)\", () => {\n      const schema = {\n        type: \"object\",\n        properties: {\n          data: {\n            anyOf: [\n              { type: \"string\" },\n              { type: \"number\" },\n            ],\n          },\n        },\n      };\n\n      const result = cleanJSONSchemaForAntigravity(schema);\n\n      expect(result.properties.data.enum).toBeUndefined();\n      expect(result.properties.data.type).toBe(\"string\");\n    });\n\n    it(\"preserves parent description when merging enum\", () => {\n      const schema = {\n        type: \"object\",\n        properties: {\n          format: {\n            description: \"Output format for the content\",\n            anyOf: [\n              { const: \"text\" },\n              { const: \"markdown\" },\n            ],\n          },\n        },\n      };\n\n      const result = cleanJSONSchemaForAntigravity(schema);\n\n      expect(result.properties.format.enum).toEqual([\"text\", \"markdown\"]);\n      expect(result.properties.format.description).toContain(\"Output format\");\n    });\n  });\n\n  it(\"adds enum hints to description\", () => {\n    const schema = {\n      type: \"object\",\n      properties: {\n        status: {\n          type: \"string\",\n          enum: [\"active\", \"inactive\", \"pending\"],\n        },\n      },\n    };\n\n    const result = cleanJSONSchemaForAntigravity(schema);\n\n    expect(result.properties.status.description).toContain(\"Allowed:\");\n    expect(result.properties.status.description).toContain(\"active\");\n    expect(result.properties.status.description).toContain(\"inactive\");\n    expect(result.properties.status.description).toContain(\"pending\");\n  });\n\n  it(\"preserves existing enum array\", () => {\n    const schema = {\n      type: \"object\",\n      properties: {\n        level: {\n          type: \"string\",\n          enum: [\"low\", \"medium\", \"high\"],\n        },\n      },\n    };\n\n    const result = cleanJSONSchemaForAntigravity(schema);\n\n    expect(result.properties.level.enum).toEqual([\"low\", \"medium\", \"high\"]);\n  });\n});\n\ndescribe(\"createSyntheticErrorResponse\", () => {\n  it(\"returns a Response with 200 OK status\", async () => {\n    const response = createSyntheticErrorResponse(\"Test error\", \"claude-sonnet\");\n    expect(response.status).toBe(200);\n    expect(response.headers.get(\"content-type\")).toBe(\"text/event-stream\");\n  });\n\n  it(\"includes error message in SSE stream content\", async () => {\n    const response = createSyntheticErrorResponse(\"Context too long\", \"claude-sonnet\");\n    const text = await response.text();\n\n    expect(text).toContain(\"Context too long\");\n    expect(text).toContain(\"data:\");\n    expect(text).toContain(\"message_start\");\n    expect(text).toContain(\"message_stop\");\n  });\n\n  it(\"uses provided model in message_start event\", async () => {\n    const response = createSyntheticErrorResponse(\"Error\", \"claude-opus-4\");\n    const text = await response.text();\n\n    expect(text).toContain(\"claude-opus-4\");\n  });\n\n  it(\"generates valid Claude SSE event structure\", async () => {\n    const response = createSyntheticErrorResponse(\"Test\", \"test-model\");\n    const text = await response.text();\n    const lines = text.split(\"\\n\").filter((l) => l.startsWith(\"data:\"));\n\n    expect(lines.length).toBeGreaterThanOrEqual(5);\n\n    const events = lines.map((l) => JSON.parse(l.replace(\"data: \", \"\")));\n    const eventTypes = events.map((e) => e.type);\n\n    expect(eventTypes).toContain(\"message_start\");\n    expect(eventTypes).toContain(\"content_block_start\");\n    expect(eventTypes).toContain(\"content_block_delta\");\n    expect(eventTypes).toContain(\"content_block_stop\");\n    expect(eventTypes).toContain(\"message_stop\");\n  });\n\n  it(\"includes error message in content_block_delta\", async () => {\n    const response = createSyntheticErrorResponse(\"Something failed\", \"model\");\n    const text = await response.text();\n    const lines = text.split(\"\\n\").filter((l) => l.startsWith(\"data:\"));\n    const events = lines.map((l) => JSON.parse(l.replace(\"data: \", \"\")));\n    const delta = events.find((e) => e.type === \"content_block_delta\");\n\n    expect(delta?.delta?.text).toBe(\"Something failed\");\n  });\n\n  it(\"sets end_turn stop reason in message_delta\", async () => {\n    const response = createSyntheticErrorResponse(\"Error\", \"model\");\n    const text = await response.text();\n    const lines = text.split(\"\\n\").filter((l) => l.startsWith(\"data:\"));\n    const events = lines.map((l) => JSON.parse(l.replace(\"data: \", \"\")));\n    const messageDelta = events.find((e) => e.type === \"message_delta\");\n\n    expect(messageDelta?.delta?.stop_reason).toBe(\"end_turn\");\n  });\n});\n\ndescribe(\"extractVariantThinkingConfig\", () => {\n  it(\"returns undefined for undefined input\", () => {\n    expect(extractVariantThinkingConfig(undefined)).toBeUndefined();\n  });\n\n  it(\"returns undefined for empty object\", () => {\n    expect(extractVariantThinkingConfig({})).toBeUndefined();\n  });\n\n  it(\"returns undefined when google key is missing\", () => {\n    expect(extractVariantThinkingConfig({ other: {} })).toBeUndefined();\n  });\n\n  it(\"extracts thinkingLevel from Gemini 3 native format\", () => {\n    const result = extractVariantThinkingConfig({\n      google: { thinkingLevel: \"high\" },\n    });\n    expect(result).toEqual({ thinkingLevel: \"high\", includeThoughts: undefined });\n  });\n\n  it(\"extracts thinkingLevel with includeThoughts\", () => {\n    const result = extractVariantThinkingConfig({\n      google: { thinkingLevel: \"medium\", includeThoughts: true },\n    });\n    expect(result).toEqual({ thinkingLevel: \"medium\", includeThoughts: true });\n  });\n\n  it(\"extracts thinkingLevel with includeThoughts false\", () => {\n    const result = extractVariantThinkingConfig({\n      google: { thinkingLevel: \"low\", includeThoughts: false },\n    });\n    expect(result).toEqual({ thinkingLevel: \"low\", includeThoughts: false });\n  });\n\n  it(\"extracts thinkingBudget from budget-based format (Claude/Gemini 2.5)\", () => {\n    const result = extractVariantThinkingConfig({\n      google: { thinkingConfig: { thinkingBudget: 16384 } },\n    });\n    expect(result).toEqual({ thinkingBudget: 16384 });\n  });\n\n  it(\"prioritizes thinkingLevel over thinkingBudget\", () => {\n    const result = extractVariantThinkingConfig({\n      google: { \n        thinkingLevel: \"high\",\n        thinkingConfig: { thinkingBudget: 8192 },\n      },\n    });\n    expect(result).toEqual({ thinkingLevel: \"high\", includeThoughts: undefined });\n  });\n\n  it(\"returns undefined for invalid thinkingLevel type\", () => {\n    expect(extractVariantThinkingConfig({\n      google: { thinkingLevel: 123 },\n    })).toBeUndefined();\n  });\n\n  it(\"returns undefined for invalid thinkingBudget type\", () => {\n    expect(extractVariantThinkingConfig({\n      google: { thinkingConfig: { thinkingBudget: \"high\" } },\n    })).toBeUndefined();\n  });\n\n  it(\"extracts thinkingBudget from generationConfig when providerOptions is undefined\", () => {\n    const result = extractVariantThinkingConfig(undefined, {\n      thinkingConfig: { thinkingBudget: 8192 },\n    });\n    expect(result).toEqual({ thinkingBudget: 8192 });\n  });\n\n  it(\"extracts thinkingBudget from generationConfig when providerOptions has no google key\", () => {\n    const result = extractVariantThinkingConfig({}, {\n      thinkingConfig: { thinkingBudget: 4096 },\n    });\n    expect(result).toEqual({ thinkingBudget: 4096 });\n  });\n\n  it(\"extracts thinkingLevel from generationConfig when providerOptions is undefined\", () => {\n    const result = extractVariantThinkingConfig(undefined, {\n      thinkingConfig: { thinkingLevel: \"high\", includeThoughts: true },\n    });\n    expect(result).toEqual({ thinkingLevel: \"high\", includeThoughts: true });\n  });\n\n  it(\"extracts thinkingLevel from generationConfig when providerOptions has no google key\", () => {\n    const result = extractVariantThinkingConfig({}, {\n      thinkingConfig: { thinkingLevel: \"low\", includeThoughts: false },\n    });\n    expect(result).toEqual({ thinkingLevel: \"low\", includeThoughts: false });\n  });\n\n  it(\"prefers providerOptions over generationConfig\", () => {\n    const result = extractVariantThinkingConfig(\n      { google: { thinkingConfig: { thinkingBudget: 32000 } } },\n      { thinkingConfig: { thinkingBudget: 8192 } },\n    );\n    expect(result).toEqual({ thinkingBudget: 32000 });\n  });\n\n  it(\"prefers providerOptions thinkingLevel over generationConfig budget\", () => {\n    const result = extractVariantThinkingConfig(\n      { google: { thinkingLevel: \"low\" } },\n      { thinkingConfig: { thinkingBudget: 8192 } },\n    );\n    expect(result).toEqual({ thinkingLevel: \"low\" });\n  });\n\n  it(\"ignores generationConfig when providerOptions has googleSearch only\", () => {\n    const result = extractVariantThinkingConfig(\n      { google: { googleSearch: { mode: \"auto\" } } },\n      { thinkingConfig: { thinkingBudget: 8192 } },\n    );\n    expect(result).toEqual({\n      googleSearch: { mode: \"auto\" },\n      thinkingBudget: 8192,\n    });\n  });\n\n  it(\"does not overwrite thinkingBudget: 0 from providerOptions with generationConfig fallback\", () => {\n    const result = extractVariantThinkingConfig(\n      { google: { thinkingConfig: { thinkingBudget: 0 } } },\n      { thinkingConfig: { thinkingBudget: 8192 } },\n    );\n    expect(result).toEqual({ thinkingBudget: 0 });\n  });\n\n  it(\"returns undefined when both sources have no thinking config\", () => {\n    expect(extractVariantThinkingConfig(undefined, {})).toBeUndefined();\n    expect(extractVariantThinkingConfig(undefined, { temperature: 0.5 })).toBeUndefined();\n  });\n});\n\ndescribe(\"deduplicateThinkingText\", () => {\n  function createTestBuffer() {\n    return createThoughtBuffer();\n  }\n\n  it(\"returns non-object input unchanged\", () => {\n    const buffer = createTestBuffer();\n    expect(deduplicateThinkingText(null, buffer)).toBeNull();\n    expect(deduplicateThinkingText(undefined, buffer)).toBeUndefined();\n    expect(deduplicateThinkingText(\"string\", buffer)).toBe(\"string\");\n  });\n\n  it(\"extracts delta from accumulated Gemini thinking text\", () => {\n    const buffer = createTestBuffer();\n    \n    const chunk1 = {\n      candidates: [{\n        content: {\n          parts: [{ thought: true, text: \"Hello \" }],\n        },\n      }],\n    };\n    // eslint-disable-next-line @typescript-eslint/no-explicit-any\n    const result1 = deduplicateThinkingText(chunk1, buffer) as any;\n    expect(result1.candidates[0].content.parts[0].text).toBe(\"Hello \");\n    \n    const chunk2 = {\n      candidates: [{\n        content: {\n          parts: [{ thought: true, text: \"Hello world\" }],\n        },\n      }],\n    };\n    // eslint-disable-next-line @typescript-eslint/no-explicit-any\n    const result2 = deduplicateThinkingText(chunk2, buffer) as any;\n    expect(result2.candidates[0].content.parts[0].text).toBe(\"world\");\n  });\n\n  it(\"filters out empty delta parts\", () => {\n    const buffer = createTestBuffer();\n    \n    const chunk1 = {\n      candidates: [{\n        content: {\n          parts: [{ thought: true, text: \"Complete thought\" }],\n        },\n      }],\n    };\n    deduplicateThinkingText(chunk1, buffer);\n    \n    const chunk2 = {\n      candidates: [{\n        content: {\n          parts: [\n            { thought: true, text: \"Complete thought\" },\n            { text: \"Regular text\" },\n          ],\n        },\n      }],\n    };\n    // eslint-disable-next-line @typescript-eslint/no-explicit-any\n    const result2 = deduplicateThinkingText(chunk2, buffer) as any;\n    expect(result2.candidates[0].content.parts).toHaveLength(1);\n    expect(result2.candidates[0].content.parts[0].text).toBe(\"Regular text\");\n  });\n\n  it(\"extracts delta from accumulated Claude thinking blocks\", () => {\n    const buffer = createTestBuffer();\n    \n    const chunk1 = {\n      content: [{ type: \"thinking\", thinking: \"First \" }],\n    };\n    // eslint-disable-next-line @typescript-eslint/no-explicit-any\n    const result1 = deduplicateThinkingText(chunk1, buffer) as any;\n    expect(result1.content[0].thinking).toBe(\"First \");\n    \n    const chunk2 = {\n      content: [{ type: \"thinking\", thinking: \"First part\" }],\n    };\n    // eslint-disable-next-line @typescript-eslint/no-explicit-any\n    const result2 = deduplicateThinkingText(chunk2, buffer) as any;\n    expect(result2.content[0].thinking).toBe(\"part\");\n  });\n\n  it(\"handles new thinking content that does not start with sent text\", () => {\n    const buffer = createTestBuffer();\n    \n    const chunk1 = {\n      candidates: [{\n        content: {\n          parts: [{ thought: true, text: \"Old thought\" }],\n        },\n      }],\n    };\n    deduplicateThinkingText(chunk1, buffer);\n    \n    const chunk2 = {\n      candidates: [{\n        content: {\n          parts: [{ thought: true, text: \"New thought\" }],\n        },\n      }],\n    };\n    // eslint-disable-next-line @typescript-eslint/no-explicit-any\n    const result2 = deduplicateThinkingText(chunk2, buffer) as any;\n    expect(result2.candidates[0].content.parts[0].text).toBe(\"New thought\");\n  });\n\n  it(\"preserves non-thinking parts unchanged\", () => {\n    const buffer = createTestBuffer();\n    \n    const chunk = {\n      candidates: [{\n        content: {\n          parts: [\n            { thought: true, text: \"Thinking\" },\n            { text: \"Regular text\" },\n            { functionCall: { name: \"test\" } },\n          ],\n        },\n      }],\n    };\n    // eslint-disable-next-line @typescript-eslint/no-explicit-any\n    const result = deduplicateThinkingText(chunk, buffer) as any;\n    expect(result.candidates[0].content.parts[1].text).toBe(\"Regular text\");\n    expect(result.candidates[0].content.parts[2].functionCall.name).toBe(\"test\");\n  });\n\n\n});\n\ndescribe(\"recursivelyParseJsonStrings\", () => {\n  it(\"parses JSON strings in non-protected keys\", () => {\n    const input = { metadata: '{\"key\": \"value\"}' };\n    const result = recursivelyParseJsonStrings(input);\n    expect(result).toEqual({ metadata: { key: \"value\" } });\n  });\n\n  it(\"preserves oldString/newString even when they contain valid JSON\", () => {\n    const input = {\n      oldString: '{\"name\": \"test\"}',\n      newString: '{\"name\": \"updated\"}',\n    };\n    const result = recursivelyParseJsonStrings(input);\n    expect(result).toEqual({\n      oldString: '{\"name\": \"test\"}',\n      newString: '{\"name\": \"updated\"}',\n    });\n  });\n\n  it(\"preserves content parameter even when it contains valid JSON\", () => {\n    const input = {\n      content: '{\"dependencies\": {\"lodash\": \"^4.0.0\"}}',\n      filePath: \"/path/to/package.json\",\n    };\n    const result = recursivelyParseJsonStrings(input);\n    expect(result).toEqual({\n      content: '{\"dependencies\": {\"lodash\": \"^4.0.0\"}}',\n      filePath: \"/path/to/package.json\",\n    });\n  });\n\n  it(\"parses JSON in non-protected keys\", () => {\n    const input = {\n      metadata: '{\"version\": 1}',\n      oldString: '{\"should\": \"stay\"}',\n    };\n    const result = recursivelyParseJsonStrings(input);\n    expect(result).toEqual({\n      metadata: { version: 1 },\n      oldString: '{\"should\": \"stay\"}',\n    });\n  });\n\n  it(\"handles nested objects with protected keys\", () => {\n    const input = {\n      tool: {\n        name: \"edit\",\n        args: {\n          oldString: '[\"item1\", \"item2\"]',\n          newString: '[\"item1\", \"item2\", \"item3\"]',\n        },\n      },\n    };\n    const result = recursivelyParseJsonStrings(input);\n    expect(result).toEqual({\n      tool: {\n        name: \"edit\",\n        args: {\n          oldString: '[\"item1\", \"item2\"]',\n          newString: '[\"item1\", \"item2\", \"item3\"]',\n        },\n      },\n    });\n  });\n});\n"
  },
  {
    "path": "src/plugin/request-helpers.ts",
    "content": "import { getKeepThinking } from \"./config\";\nimport { createLogger } from \"./logger\";\nimport { cacheSignature } from \"./cache\";\nimport {\n  EMPTY_SCHEMA_PLACEHOLDER_NAME,\n  EMPTY_SCHEMA_PLACEHOLDER_DESCRIPTION,\n  SKIP_THOUGHT_SIGNATURE,\n} from \"../constants\";\nimport { processImageData } from \"./image-saver\";\nimport type { GoogleSearchConfig } from \"./transform/types\";\n\nconst log = createLogger(\"request-helpers\");\n\nconst ANTIGRAVITY_PREVIEW_LINK = \"https://goo.gle/enable-preview-features\"; // TODO: Update to Antigravity link if available\n\n// ============================================================================\n// JSON SCHEMA CLEANING FOR ANTIGRAVITY API\n// Ported from CLIProxyAPI's CleanJSONSchemaForAntigravity (gemini_schema.go)\n// ============================================================================\n\n/**\n * Unsupported constraint keywords that should be moved to description hints.\n * Claude/Gemini reject these in VALIDATED mode.\n */\nconst UNSUPPORTED_CONSTRAINTS = [\n  \"minLength\", \"maxLength\", \"exclusiveMinimum\", \"exclusiveMaximum\",\n  \"pattern\", \"minItems\", \"maxItems\", \"format\",\n  \"default\", \"examples\",\n] as const;\n\n/**\n * Keywords that should be removed after hint extraction.\n */\nconst UNSUPPORTED_KEYWORDS = [\n  ...UNSUPPORTED_CONSTRAINTS,\n  \"$schema\", \"$defs\", \"definitions\", \"const\", \"$ref\", \"additionalProperties\",\n  \"propertyNames\", \"title\", \"$id\", \"$comment\",\n] as const;\n\n/**\n * Appends a hint to a schema's description field.\n */\nfunction appendDescriptionHint(schema: any, hint: string): any {\n  if (!schema || typeof schema !== \"object\") {\n    return schema;\n  }\n  const existing = typeof schema.description === \"string\" ? schema.description : \"\";\n  const newDescription = existing ? `${existing} (${hint})` : hint;\n  return { ...schema, description: newDescription };\n}\n\n/**\n * Phase 1a: Converts $ref to description hints.\n * $ref: \"#/$defs/Foo\" → { type: \"object\", description: \"See: Foo\" }\n */\nfunction convertRefsToHints(schema: any): any {\n  if (!schema || typeof schema !== \"object\") {\n    return schema;\n  }\n\n  if (Array.isArray(schema)) {\n    return schema.map(item => convertRefsToHints(item));\n  }\n\n  // If this object has $ref, replace it with a hint\n  if (typeof schema.$ref === \"string\") {\n    const refVal = schema.$ref;\n    const defName = refVal.includes(\"/\") ? refVal.split(\"/\").pop() : refVal;\n    const hint = `See: ${defName}`;\n    const existingDesc = typeof schema.description === \"string\" ? schema.description : \"\";\n    const newDescription = existingDesc ? `${existingDesc} (${hint})` : hint;\n    return { type: \"object\", description: newDescription };\n  }\n\n  // Recursively process all properties\n  const result: any = {};\n  for (const [key, value] of Object.entries(schema)) {\n    result[key] = convertRefsToHints(value);\n  }\n  return result;\n}\n\n/**\n * Phase 1b: Converts const to enum.\n * { const: \"foo\" } → { enum: [\"foo\"] }\n */\nfunction convertConstToEnum(schema: any): any {\n  if (!schema || typeof schema !== \"object\") {\n    return schema;\n  }\n\n  if (Array.isArray(schema)) {\n    return schema.map(item => convertConstToEnum(item));\n  }\n\n  const result: any = {};\n  for (const [key, value] of Object.entries(schema)) {\n    if (key === \"const\" && !schema.enum) {\n      result.enum = [value];\n    } else {\n      result[key] = convertConstToEnum(value);\n    }\n  }\n  return result;\n}\n\n/**\n * Phase 1c: Adds enum hints to description.\n * { enum: [\"a\", \"b\", \"c\"] } → adds \"(Allowed: a, b, c)\" to description\n */\nfunction addEnumHints(schema: any): any {\n  if (!schema || typeof schema !== \"object\") {\n    return schema;\n  }\n\n  if (Array.isArray(schema)) {\n    return schema.map(item => addEnumHints(item));\n  }\n\n  let result: any = { ...schema };\n\n  // Add enum hint if enum has 2-10 items\n  if (Array.isArray(result.enum) && result.enum.length > 1 && result.enum.length <= 10) {\n    const vals = result.enum.map((v: any) => String(v)).join(\", \");\n    result = appendDescriptionHint(result, `Allowed: ${vals}`);\n  }\n\n  // Recursively process nested objects\n  for (const [key, value] of Object.entries(result)) {\n    if (key !== \"enum\" && typeof value === \"object\" && value !== null) {\n      result[key] = addEnumHints(value);\n    }\n  }\n\n  return result;\n}\n\n/**\n * Phase 1d: Adds additionalProperties hints.\n * { additionalProperties: false } → adds \"(No extra properties allowed)\" to description\n */\nfunction addAdditionalPropertiesHints(schema: any): any {\n  if (!schema || typeof schema !== \"object\") {\n    return schema;\n  }\n\n  if (Array.isArray(schema)) {\n    return schema.map(item => addAdditionalPropertiesHints(item));\n  }\n\n  let result: any = { ...schema };\n\n  if (result.additionalProperties === false) {\n    result = appendDescriptionHint(result, \"No extra properties allowed\");\n  }\n\n  // Recursively process nested objects\n  for (const [key, value] of Object.entries(result)) {\n    if (key !== \"additionalProperties\" && typeof value === \"object\" && value !== null) {\n      result[key] = addAdditionalPropertiesHints(value);\n    }\n  }\n\n  return result;\n}\n\n/**\n * Phase 1e: Moves unsupported constraints to description hints.\n * { minLength: 1, maxLength: 100 } → adds \"(minLength: 1) (maxLength: 100)\" to description\n */\nfunction moveConstraintsToDescription(schema: any): any {\n  if (!schema || typeof schema !== \"object\") {\n    return schema;\n  }\n\n  if (Array.isArray(schema)) {\n    return schema.map(item => moveConstraintsToDescription(item));\n  }\n\n  let result: any = { ...schema };\n\n  // Move constraint values to description\n  for (const constraint of UNSUPPORTED_CONSTRAINTS) {\n    if (result[constraint] !== undefined && typeof result[constraint] !== \"object\") {\n      result = appendDescriptionHint(result, `${constraint}: ${result[constraint]}`);\n    }\n  }\n\n  // Recursively process nested objects\n  for (const [key, value] of Object.entries(result)) {\n    if (typeof value === \"object\" && value !== null) {\n      result[key] = moveConstraintsToDescription(value);\n    }\n  }\n\n  return result;\n}\n\n/**\n * Phase 2a: Merges allOf schemas into a single object.\n * { allOf: [{ properties: { a: ... } }, { properties: { b: ... } }] }\n * → { properties: { a: ..., b: ... } }\n */\nfunction mergeAllOf(schema: any): any {\n  if (!schema || typeof schema !== \"object\") {\n    return schema;\n  }\n\n  if (Array.isArray(schema)) {\n    return schema.map(item => mergeAllOf(item));\n  }\n\n  let result: any = { ...schema };\n\n  // If this object has allOf, merge its contents\n  if (Array.isArray(result.allOf)) {\n    const merged: any = {};\n    const mergedRequired: string[] = [];\n\n    for (const item of result.allOf) {\n      if (!item || typeof item !== \"object\") continue;\n\n      // Merge properties\n      if (item.properties && typeof item.properties === \"object\") {\n        merged.properties = { ...merged.properties, ...item.properties };\n      }\n\n      // Merge required arrays\n      if (Array.isArray(item.required)) {\n        for (const req of item.required) {\n          if (!mergedRequired.includes(req)) {\n            mergedRequired.push(req);\n          }\n        }\n      }\n\n      // Copy other fields from allOf items\n      for (const [key, value] of Object.entries(item)) {\n        if (key !== \"properties\" && key !== \"required\" && merged[key] === undefined) {\n          merged[key] = value;\n        }\n      }\n    }\n\n    // Apply merged content to result\n    if (merged.properties) {\n      result.properties = { ...result.properties, ...merged.properties };\n    }\n    if (mergedRequired.length > 0) {\n      const existingRequired = Array.isArray(result.required) ? result.required : [];\n      result.required = Array.from(new Set([...existingRequired, ...mergedRequired]));\n    }\n\n    // Copy other merged fields\n    for (const [key, value] of Object.entries(merged)) {\n      if (key !== \"properties\" && key !== \"required\" && result[key] === undefined) {\n        result[key] = value;\n      }\n    }\n\n    delete result.allOf;\n  }\n\n  // Recursively process nested objects\n  for (const [key, value] of Object.entries(result)) {\n    if (typeof value === \"object\" && value !== null) {\n      result[key] = mergeAllOf(value);\n    }\n  }\n\n  return result;\n}\n\n/**\n * Scores a schema option for selection in anyOf/oneOf flattening.\n * Higher score = more preferred.\n */\nfunction scoreSchemaOption(schema: any): { score: number; typeName: string } {\n  if (!schema || typeof schema !== \"object\") {\n    return { score: 0, typeName: \"unknown\" };\n  }\n\n  const type = schema.type;\n\n  // Object or has properties = highest priority\n  if (type === \"object\" || schema.properties) {\n    return { score: 3, typeName: \"object\" };\n  }\n\n  // Array or has items = second priority\n  if (type === \"array\" || schema.items) {\n    return { score: 2, typeName: \"array\" };\n  }\n\n  // Any other non-null type\n  if (type && type !== \"null\") {\n    return { score: 1, typeName: type };\n  }\n\n  // Null or no type\n  return { score: 0, typeName: type || \"null\" };\n}\n\n/**\n * Checks if an anyOf/oneOf array represents enum choices.\n * Returns the merged enum values if so, otherwise null.\n *\n * Handles patterns like:\n * - anyOf: [{ const: \"a\" }, { const: \"b\" }]\n * - anyOf: [{ enum: [\"a\"] }, { enum: [\"b\"] }]\n * - anyOf: [{ type: \"string\", const: \"a\" }, { type: \"string\", const: \"b\" }]\n */\nfunction tryMergeEnumFromUnion(options: any[]): string[] | null {\n  if (!Array.isArray(options) || options.length === 0) {\n    return null;\n  }\n\n  const enumValues: string[] = [];\n\n  for (const option of options) {\n    if (!option || typeof option !== \"object\") {\n      return null;\n    }\n\n    // Check for const value\n    if (option.const !== undefined) {\n      enumValues.push(String(option.const));\n      continue;\n    }\n\n    // Check for single-value enum\n    if (Array.isArray(option.enum) && option.enum.length === 1) {\n      enumValues.push(String(option.enum[0]));\n      continue;\n    }\n\n    // Check for multi-value enum (merge all values)\n    if (Array.isArray(option.enum) && option.enum.length > 0) {\n      for (const val of option.enum) {\n        enumValues.push(String(val));\n      }\n      continue;\n    }\n\n    // If option has complex structure (properties, items, etc.), it's not a simple enum\n    if (option.properties || option.items || option.anyOf || option.oneOf || option.allOf) {\n      return null;\n    }\n\n    // If option has only type (no const/enum), it's not an enum pattern\n    if (option.type && !option.const && !option.enum) {\n      return null;\n    }\n  }\n\n  // Only return if we found actual enum values\n  return enumValues.length > 0 ? enumValues : null;\n}\n\n/**\n * Phase 2b: Flattens anyOf/oneOf to the best option with type hints.\n * { anyOf: [{ type: \"string\" }, { type: \"number\" }] }\n * → { type: \"string\", description: \"(Accepts: string | number)\" }\n *\n * Special handling for enum patterns:\n * { anyOf: [{ const: \"a\" }, { const: \"b\" }] }\n * → { type: \"string\", enum: [\"a\", \"b\"] }\n */\nfunction flattenAnyOfOneOf(schema: any): any {\n  if (!schema || typeof schema !== \"object\") {\n    return schema;\n  }\n\n  if (Array.isArray(schema)) {\n    return schema.map(item => flattenAnyOfOneOf(item));\n  }\n\n  let result: any = { ...schema };\n\n  // Process anyOf or oneOf\n  for (const unionKey of [\"anyOf\", \"oneOf\"] as const) {\n    if (Array.isArray(result[unionKey]) && result[unionKey].length > 0) {\n      const options = result[unionKey];\n      const parentDesc = typeof result.description === \"string\" ? result.description : \"\";\n\n      // First, check if this is an enum pattern (anyOf with const/enum values)\n      // This is crucial for tools like WebFetch where format: anyOf[{const:\"text\"},{const:\"markdown\"},{const:\"html\"}]\n      const mergedEnum = tryMergeEnumFromUnion(options);\n      if (mergedEnum !== null) {\n        // This is an enum pattern - merge all values into a single enum\n        const { [unionKey]: _, ...rest } = result;\n        result = {\n          ...rest,\n          type: \"string\",\n          enum: mergedEnum,\n        };\n        // Preserve parent description\n        if (parentDesc) {\n          result.description = parentDesc;\n        }\n        continue;\n      }\n\n      // Not an enum pattern - use standard flattening logic\n      // Score each option and find the best\n      let bestIdx = 0;\n      let bestScore = -1;\n      const allTypes: string[] = [];\n\n      for (let i = 0; i < options.length; i++) {\n        const { score, typeName } = scoreSchemaOption(options[i]);\n        if (typeName) {\n          allTypes.push(typeName);\n        }\n        if (score > bestScore) {\n          bestScore = score;\n          bestIdx = i;\n        }\n      }\n\n      // Select the best option and flatten it recursively\n      let selected = flattenAnyOfOneOf(options[bestIdx]) || { type: \"string\" };\n\n      // Preserve parent description\n      if (parentDesc) {\n        const childDesc = typeof selected.description === \"string\" ? selected.description : \"\";\n        if (childDesc && childDesc !== parentDesc) {\n          selected = { ...selected, description: `${parentDesc} (${childDesc})` };\n        } else if (!childDesc) {\n          selected = { ...selected, description: parentDesc };\n        }\n      }\n\n      if (allTypes.length > 1) {\n        const uniqueTypes = Array.from(new Set(allTypes));\n        const hint = `Accepts: ${uniqueTypes.join(\" | \")}`;\n        selected = appendDescriptionHint(selected, hint);\n      }\n\n      // Replace result with selected schema, preserving other fields\n      const { [unionKey]: _, description: __, ...rest } = result;\n      result = { ...rest, ...selected };\n    }\n  }\n\n  // Recursively process nested objects\n  for (const [key, value] of Object.entries(result)) {\n    if (typeof value === \"object\" && value !== null) {\n      result[key] = flattenAnyOfOneOf(value);\n    }\n  }\n\n  return result;\n}\n\n/**\n * Phase 2c: Flattens type arrays to single type with nullable hint.\n * { type: [\"string\", \"null\"] } → { type: \"string\", description: \"(nullable)\" }\n */\nfunction flattenTypeArrays(schema: any, nullableFields?: Map<string, string[]>, currentPath?: string): any {\n  if (!schema || typeof schema !== \"object\") {\n    return schema;\n  }\n\n  if (Array.isArray(schema)) {\n    return schema.map((item, idx) => flattenTypeArrays(item, nullableFields, `${currentPath || \"\"}[${idx}]`));\n  }\n\n  let result: any = { ...schema };\n  const localNullableFields = nullableFields || new Map<string, string[]>();\n\n  // Handle type array\n  if (Array.isArray(result.type)) {\n    const types = result.type as string[];\n    const hasNull = types.includes(\"null\");\n    const nonNullTypes = types.filter(t => t !== \"null\" && t);\n\n    // Select first non-null type, or \"string\" as fallback\n    const firstType = nonNullTypes.length > 0 ? nonNullTypes[0] : \"string\";\n    result.type = firstType;\n\n    // Add hint for multiple types\n    if (nonNullTypes.length > 1) {\n      result = appendDescriptionHint(result, `Accepts: ${nonNullTypes.join(\" | \")}`);\n    }\n\n    // Add nullable hint\n    if (hasNull) {\n      result = appendDescriptionHint(result, \"nullable\");\n    }\n  }\n\n  // Recursively process properties\n  if (result.properties && typeof result.properties === \"object\") {\n    const newProps: any = {};\n    for (const [propKey, propValue] of Object.entries(result.properties)) {\n      const propPath = currentPath ? `${currentPath}.properties.${propKey}` : `properties.${propKey}`;\n      const processed = flattenTypeArrays(propValue, localNullableFields, propPath);\n      newProps[propKey] = processed;\n\n      // Track nullable fields for required array cleanup\n      if (processed && typeof processed === \"object\" && \n          typeof processed.description === \"string\" && \n          processed.description.includes(\"nullable\")) {\n        const objectPath = currentPath || \"\";\n        const existing = localNullableFields.get(objectPath) || [];\n        existing.push(propKey);\n        localNullableFields.set(objectPath, existing);\n      }\n    }\n    result.properties = newProps;\n  }\n\n  // Remove nullable fields from required array\n  if (Array.isArray(result.required) && !nullableFields) {\n    // Only at root level, filter out nullable fields\n    const nullableAtRoot = localNullableFields.get(\"\") || [];\n    if (nullableAtRoot.length > 0) {\n      result.required = result.required.filter((r: string) => !nullableAtRoot.includes(r));\n      if (result.required.length === 0) {\n        delete result.required;\n      }\n    }\n  }\n\n  // Recursively process other nested objects\n  for (const [key, value] of Object.entries(result)) {\n    if (key !== \"properties\" && typeof value === \"object\" && value !== null) {\n      result[key] = flattenTypeArrays(value, localNullableFields, `${currentPath || \"\"}.${key}`);\n    }\n  }\n\n  return result;\n}\n\n/**\n * Phase 3: Removes unsupported keywords after hints have been extracted.\n * @param insideProperties - When true, keys are property NAMES (preserve); when false, keys are JSON Schema keywords (filter).\n */\nfunction removeUnsupportedKeywords(schema: any, insideProperties: boolean = false): any {\n  if (!schema || typeof schema !== \"object\") {\n    return schema;\n  }\n\n  if (Array.isArray(schema)) {\n    return schema.map(item => removeUnsupportedKeywords(item, false));\n  }\n\n  const result: any = {};\n  for (const [key, value] of Object.entries(schema)) {\n    if (!insideProperties && (UNSUPPORTED_KEYWORDS as readonly string[]).includes(key)) {\n      continue;\n    }\n\n    if (typeof value === \"object\" && value !== null) {\n      if (key === \"properties\") {\n        const propertiesResult: any = {};\n        for (const [propName, propSchema] of Object.entries(value as object)) {\n          propertiesResult[propName] = removeUnsupportedKeywords(propSchema, false);\n        }\n        result[key] = propertiesResult;\n      } else {\n        result[key] = removeUnsupportedKeywords(value, false);\n      }\n    } else {\n      result[key] = value;\n    }\n  }\n  return result;\n}\n\n/**\n * Phase 3b: Cleans up required fields - removes entries that don't exist in properties.\n */\nfunction cleanupRequiredFields(schema: any): any {\n  if (!schema || typeof schema !== \"object\") {\n    return schema;\n  }\n\n  if (Array.isArray(schema)) {\n    return schema.map(item => cleanupRequiredFields(item));\n  }\n\n  let result: any = { ...schema };\n\n  // Clean up required array if properties exist\n  if (Array.isArray(result.required) && result.properties && typeof result.properties === \"object\") {\n    const validRequired = result.required.filter((req: string) => \n      Object.prototype.hasOwnProperty.call(result.properties, req)\n    );\n    if (validRequired.length === 0) {\n      delete result.required;\n    } else if (validRequired.length !== result.required.length) {\n      result.required = validRequired;\n    }\n  }\n\n  // Recursively process nested objects\n  for (const [key, value] of Object.entries(result)) {\n    if (typeof value === \"object\" && value !== null) {\n      result[key] = cleanupRequiredFields(value);\n    }\n  }\n\n  return result;\n}\n\n/**\n * Phase 4: Adds placeholder property for empty object schemas.\n * Claude VALIDATED mode requires at least one property.\n */\nfunction addEmptySchemaPlaceholder(schema: any): any {\n  if (!schema || typeof schema !== \"object\") {\n    return schema;\n  }\n\n  if (Array.isArray(schema)) {\n    return schema.map(item => addEmptySchemaPlaceholder(item));\n  }\n\n  let result: any = { ...schema };\n\n  // Check if this is an empty object schema\n  const isObjectType = result.type === \"object\";\n\n  if (isObjectType) {\n    const hasProperties =\n      result.properties &&\n      typeof result.properties === \"object\" &&\n      Object.keys(result.properties).length > 0;\n\n    if (!hasProperties) {\n      result.properties = {\n        [EMPTY_SCHEMA_PLACEHOLDER_NAME]: {\n          type: \"boolean\",\n          description: EMPTY_SCHEMA_PLACEHOLDER_DESCRIPTION,\n        },\n      };\n      result.required = [EMPTY_SCHEMA_PLACEHOLDER_NAME];\n    }\n  }\n\n  // Recursively process nested objects\n  for (const [key, value] of Object.entries(result)) {\n    if (typeof value === \"object\" && value !== null) {\n      result[key] = addEmptySchemaPlaceholder(value);\n    }\n  }\n\n  return result;\n}\n\n/**\n * Cleans a JSON schema for Antigravity API compatibility.\n * Transforms unsupported features into description hints while preserving semantic information.\n * \n * Ported from CLIProxyAPI's CleanJSONSchemaForAntigravity (gemini_schema.go)\n */\nexport function cleanJSONSchemaForAntigravity(schema: any): any {\n  if (!schema || typeof schema !== \"object\") {\n    return schema;\n  }\n\n  let result = schema;\n\n  // Phase 1: Convert and add hints\n  result = convertRefsToHints(result);\n  result = convertConstToEnum(result);\n  result = addEnumHints(result);\n  result = addAdditionalPropertiesHints(result);\n  result = moveConstraintsToDescription(result);\n\n  // Phase 2: Flatten complex structures\n  result = mergeAllOf(result);\n  result = flattenAnyOfOneOf(result);\n  result = flattenTypeArrays(result);\n\n  // Phase 3: Cleanup\n  result = removeUnsupportedKeywords(result);\n  result = cleanupRequiredFields(result);\n\n  // Phase 4: Add placeholder for empty object schemas\n  result = addEmptySchemaPlaceholder(result);\n\n  return result;\n}\n\n// ============================================================================\n// END JSON SCHEMA CLEANING\n// ============================================================================\n\nexport interface AntigravityApiError {\n  code?: number;\n  message?: string;\n  status?: string;\n  [key: string]: unknown;\n}\n\n/**\n * Minimal representation of Antigravity API responses we touch.\n */\nexport interface AntigravityApiBody {\n  response?: unknown;\n  error?: AntigravityApiError;\n  [key: string]: unknown;\n}\n\n/**\n * Usage metadata exposed by Antigravity responses. Fields are optional to reflect partial payloads.\n */\nexport interface AntigravityUsageMetadata {\n  totalTokenCount?: number;\n  promptTokenCount?: number;\n  candidatesTokenCount?: number;\n  cachedContentTokenCount?: number;\n  thoughtsTokenCount?: number;\n}\n\n/**\n * Normalized thinking configuration accepted by Antigravity.\n */\nexport interface ThinkingConfig {\n  thinkingBudget?: number;\n  includeThoughts?: boolean;\n}\n\n/**\n * Default token budget for thinking/reasoning. 16000 tokens provides sufficient\n * space for complex reasoning while staying within typical model limits.\n */\nexport const DEFAULT_THINKING_BUDGET = 16000;\n\n/**\n * Checks if a model name indicates thinking/reasoning capability.\n * Models with \"thinking\", \"gemini-3\", or \"opus\" in their name support extended thinking.\n */\nexport function isThinkingCapableModel(modelName: string): boolean {\n  const lowerModel = modelName.toLowerCase();\n  return lowerModel.includes(\"thinking\")\n    || lowerModel.includes(\"gemini-3\")\n    || lowerModel.includes(\"opus\");\n}\n\n/**\n * Extracts thinking configuration from various possible request locations.\n * Supports both Gemini-style thinkingConfig and Anthropic-style thinking options.\n */\nexport function extractThinkingConfig(\n  requestPayload: Record<string, unknown>,\n  rawGenerationConfig: Record<string, unknown> | undefined,\n  extraBody: Record<string, unknown> | undefined,\n): ThinkingConfig | undefined {\n  const thinkingConfig = rawGenerationConfig?.thinkingConfig\n    ?? extraBody?.thinkingConfig\n    ?? requestPayload.thinkingConfig;\n\n  if (thinkingConfig && typeof thinkingConfig === \"object\") {\n    const config = thinkingConfig as Record<string, unknown>;\n    return {\n      includeThoughts: Boolean(config.includeThoughts),\n      thinkingBudget: typeof config.thinkingBudget === \"number\" ? config.thinkingBudget : DEFAULT_THINKING_BUDGET,\n    };\n  }\n\n  // Convert Anthropic-style \"thinking\" option: { type: \"enabled\", budgetTokens: N }\n  const anthropicThinking = extraBody?.thinking ?? requestPayload.thinking;\n  if (anthropicThinking && typeof anthropicThinking === \"object\") {\n    const thinking = anthropicThinking as Record<string, unknown>;\n    if (thinking.type === \"enabled\" || thinking.budgetTokens) {\n      return {\n        includeThoughts: true,\n        thinkingBudget: typeof thinking.budgetTokens === \"number\" ? thinking.budgetTokens : DEFAULT_THINKING_BUDGET,\n      };\n    }\n  }\n\n  return undefined;\n}\n\n/**\n * Variant thinking config extracted from OpenCode's providerOptions.\n */\nexport interface VariantThinkingConfig {\n  /** Gemini 3 native thinking level (low/medium/high) */\n  thinkingLevel?: string;\n  /** Numeric thinking budget for Claude and Gemini 2.5 */\n  thinkingBudget?: number;\n  /** Whether to include thoughts in output */\n  includeThoughts?: boolean;\n  /** Google Search configuration */\n  googleSearch?: GoogleSearchConfig;\n}\n\n/**\n * Extracts variant thinking config from OpenCode's providerOptions.\n * \n * All Antigravity models route through the Google provider, so we only check\n * providerOptions.google. Supports two formats:\n * \n * 1. Gemini 3 native: { google: { thinkingLevel: \"high\", includeThoughts: true } }\n * 2. Budget-based (Claude/Gemini 2.5): { google: { thinkingConfig: { thinkingBudget: 32000 } } }\n * \n * When providerOptions is missing or has no thinking config (common with OpenCode\n * model variants), falls back to extracting from generationConfig directly:\n * 3. generationConfig fallback: { thinkingConfig: { thinkingBudget: 8192 } }\n */\nexport function extractVariantThinkingConfig(\n  providerOptions: Record<string, unknown> | undefined,\n  generationConfig?: Record<string, unknown> | undefined\n): VariantThinkingConfig | undefined {\n  const result: VariantThinkingConfig = {};\n\n  // Primary path: extract from providerOptions.google\n  const google = (providerOptions?.google) as Record<string, unknown> | undefined;\n  if (google) {\n    // Gemini 3 native format: { google: { thinkingLevel: \"high\", includeThoughts: true } }\n    // thinkingLevel takes priority over thinkingBudget - they are mutually exclusive\n    if (typeof google.thinkingLevel === \"string\") {\n      result.thinkingLevel = google.thinkingLevel;\n      result.includeThoughts = typeof google.includeThoughts === \"boolean\" ? google.includeThoughts : undefined;\n    } else if (google.thinkingConfig && typeof google.thinkingConfig === \"object\") {\n      // Budget-based format (Claude/Gemini 2.5): { google: { thinkingConfig: { thinkingBudget } } }\n      // Only used when thinkingLevel is not present\n      const tc = google.thinkingConfig as Record<string, unknown>;\n      if (typeof tc.thinkingBudget === \"number\") {\n        result.thinkingBudget = tc.thinkingBudget;\n      }\n    }\n\n    // Extract Google Search config\n    if (google.googleSearch && typeof google.googleSearch === \"object\") {\n      const search = google.googleSearch as Record<string, unknown>;\n      result.googleSearch = {\n        mode: search.mode === 'auto' || search.mode === 'off' ? search.mode : undefined,\n        threshold: typeof search.threshold === 'number' ? search.threshold : undefined,\n      };\n    }\n  }\n\n  // Fallback: OpenCode may pass thinking config in generationConfig\n  // instead of providerOptions (common when using model variants)\n  if (result.thinkingBudget === undefined && !result.thinkingLevel && generationConfig) {\n    if (generationConfig.thinkingConfig && typeof generationConfig.thinkingConfig === \"object\") {\n      const tc = generationConfig.thinkingConfig as Record<string, unknown>;\n      if (typeof tc.thinkingLevel === \"string\") {\n        // Gemini 3 native format sent via generationConfig\n        result.thinkingLevel = tc.thinkingLevel;\n        result.includeThoughts = typeof tc.includeThoughts === \"boolean\" ? tc.includeThoughts : undefined;\n      } else if (typeof tc.thinkingBudget === \"number\") {\n        result.thinkingBudget = tc.thinkingBudget;\n      }\n    }\n  }\n\n  return Object.keys(result).length > 0 ? result : undefined;\n}\n\n/**\n * Determines the final thinking configuration based on model capabilities and user settings.\n * For Claude thinking models, we keep thinking enabled even in multi-turn conversations.\n * The filterUnsignedThinkingBlocks function will handle signature validation/restoration.\n */\nexport function resolveThinkingConfig(\n  userConfig: ThinkingConfig | undefined,\n  isThinkingModel: boolean,\n  _isClaudeModel: boolean,\n  _hasAssistantHistory: boolean,\n): ThinkingConfig | undefined {\n  // For thinking-capable models (including Claude thinking models), enable thinking by default\n  // The signature validation/restoration is handled by filterUnsignedThinkingBlocks\n  if (isThinkingModel && !userConfig) {\n    return { includeThoughts: true, thinkingBudget: DEFAULT_THINKING_BUDGET };\n  }\n\n  return userConfig;\n}\n\n/**\n * Checks if a part is a thinking/reasoning block (Anthropic or Gemini style).\n */\nfunction isThinkingPart(part: Record<string, unknown>): boolean {\n  return part.type === \"thinking\"\n    || part.type === \"redacted_thinking\"\n    || part.type === \"reasoning\"\n    || part.thinking !== undefined\n    || part.thought === true;\n}\n\n/**\n * Checks if a part has a signature field (thinking block signature).\n * Used to detect foreign thinking blocks that might have unknown type values.\n */\nfunction hasSignatureField(part: Record<string, unknown>): boolean {\n  return part.signature !== undefined || part.thoughtSignature !== undefined;\n}\n\n/**\n * Checks if a part is a tool block (tool_use or tool_result).\n * Tool blocks must never be filtered - they're required for tool call/result pairing.\n * Handles multiple formats:\n * - Anthropic: { type: \"tool_use\" }, { type: \"tool_result\", tool_use_id }\n * - Nested: { tool_result: { tool_use_id } }, { tool_use: { id } }\n * - Gemini: { functionCall }, { functionResponse }\n */\nfunction isToolBlock(part: Record<string, unknown>): boolean {\n  return part.type === \"tool_use\"\n    || part.type === \"tool_result\"\n    || part.tool_use_id !== undefined\n    || part.tool_call_id !== undefined\n    || part.tool_result !== undefined\n    || part.tool_use !== undefined\n    || part.toolUse !== undefined\n    || part.functionCall !== undefined\n    || part.functionResponse !== undefined;\n}\n\n/**\n * Unconditionally strips ALL thinking/reasoning blocks from a content array.\n * Used for Claude models to avoid signature validation errors entirely.\n * Claude will generate fresh thinking for each turn.\n */\nfunction stripAllThinkingBlocks(contentArray: any[]): any[] {\n  return contentArray.filter(item => {\n    if (!item || typeof item !== \"object\") return true;\n    if (isToolBlock(item)) return true;\n    if (isThinkingPart(item)) return false;\n    if (hasSignatureField(item)) return false;\n    return true;\n  });\n}\n\n/**\n * Removes trailing thinking blocks from a content array.\n * Claude API requires that assistant messages don't end with thinking blocks.\n * Only removes unsigned thinking blocks; preserves those with valid signatures.\n */\nfunction removeTrailingThinkingBlocks(\n  contentArray: any[],\n  sessionId?: string,\n  getCachedSignatureFn?: (sessionId: string, text: string) => string | undefined,\n): any[] {\n  const result = [...contentArray];\n\n  while (result.length > 0 && isThinkingPart(result[result.length - 1])) {\n    const part = result[result.length - 1];\n    const isValid = sessionId && getCachedSignatureFn\n      ? isOurCachedSignature(part as Record<string, unknown>, sessionId, getCachedSignatureFn)\n      : hasValidSignature(part as Record<string, unknown>);\n    if (isValid) {\n      break;\n    }\n    result.pop();\n  }\n\n  return result;\n}\n\n/**\n * Checks if a thinking part has a valid signature.\n * A valid signature is a non-empty string with at least 50 characters.\n */\nfunction hasValidSignature(part: Record<string, unknown>): boolean {\n  const signature = part.thought === true ? part.thoughtSignature : part.signature;\n  return typeof signature === \"string\" && signature.length >= 50;\n}\n\n/**\n * Gets the signature from a thinking part, if present.\n */\nfunction getSignature(part: Record<string, unknown>): string | undefined {\n  const signature = part.thought === true ? part.thoughtSignature : part.signature;\n  return typeof signature === \"string\" ? signature : undefined;\n}\n\n/**\n * Checks if a thinking part's signature was generated by our plugin (exists in our cache).\n * This prevents accepting signatures from other providers (e.g., direct Anthropic API, OpenAI)\n * which would cause \"Invalid signature\" errors when sent to Antigravity Claude.\n */\nfunction isOurCachedSignature(\n  part: Record<string, unknown>,\n  sessionId: string | undefined,\n  getCachedSignatureFn: ((sessionId: string, text: string) => string | undefined) | undefined,\n): boolean {\n  if (!sessionId || !getCachedSignatureFn) {\n    return false;\n  }\n\n  const text = getThinkingText(part);\n  if (!text) {\n    return false;\n  }\n\n  const partSignature = getSignature(part);\n  if (!partSignature) {\n    return false;\n  }\n\n  const cachedSignature = getCachedSignatureFn(sessionId, text);\n  return cachedSignature === partSignature;\n}\n\n/**\n * Gets the text content from a thinking part.\n */\nfunction getThinkingText(part: Record<string, unknown>): string {\n  if (typeof part.text === \"string\") return part.text;\n  if (typeof part.thinking === \"string\") return part.thinking;\n\n  if (part.text && typeof part.text === \"object\") {\n    const maybeText = (part.text as any).text;\n    if (typeof maybeText === \"string\") return maybeText;\n  }\n\n  if (part.thinking && typeof part.thinking === \"object\") {\n    const maybeText = (part.thinking as any).text ?? (part.thinking as any).thinking;\n    if (typeof maybeText === \"string\") return maybeText;\n  }\n\n  return \"\";\n}\n\n/**\n * Recursively strips cache_control and providerOptions from any object.\n * These fields can be injected by SDKs, but Claude rejects them inside thinking blocks.\n */\nfunction stripCacheControlRecursively(obj: unknown): unknown {\n  if (obj === null || obj === undefined) return obj;\n  if (typeof obj !== \"object\") return obj;\n  if (Array.isArray(obj)) return obj.map(item => stripCacheControlRecursively(item));\n\n  const result: Record<string, unknown> = {};\n  for (const [key, value] of Object.entries(obj as Record<string, unknown>)) {\n    if (key === \"cache_control\" || key === \"providerOptions\") continue;\n    result[key] = stripCacheControlRecursively(value);\n  }\n  return result;\n}\n\n/**\n * Sanitizes a thinking part by keeping only the allowed fields.\n * In particular, ensures `thinking` is a string (not an object with cache_control).\n * Returns null if the thinking block has no valid content.\n */\nfunction sanitizeThinkingPart(part: Record<string, unknown>): Record<string, unknown> | null {\n  // Gemini-style thought blocks: { thought: true, text, thoughtSignature }\n  if (part.thought === true) {\n    let textContent: unknown = part.text;\n    if (typeof textContent === \"object\" && textContent !== null) {\n      const maybeText = (textContent as any).text;\n      textContent = typeof maybeText === \"string\" ? maybeText : undefined;\n    }\n\n    const hasContent = typeof textContent === \"string\" && textContent.trim().length > 0;\n    if (!hasContent && !part.thoughtSignature) {\n      return null;\n    }\n\n    const sanitized: Record<string, unknown> = { thought: true };\n    if (textContent !== undefined) sanitized.text = textContent;\n    if (part.thoughtSignature !== undefined) sanitized.thoughtSignature = part.thoughtSignature;\n    return sanitized;\n  }\n\n  // Anthropic-style thinking/redacted_thinking blocks: { type: \"thinking\"|\"redacted_thinking\", thinking, signature }\n  if (part.type === \"thinking\" || part.type === \"redacted_thinking\" || part.thinking !== undefined) {\n    let thinkingContent: unknown = part.thinking ?? part.text;\n    if (thinkingContent !== undefined && typeof thinkingContent === \"object\" && thinkingContent !== null) {\n      const maybeText = (thinkingContent as any).text ?? (thinkingContent as any).thinking;\n      thinkingContent = typeof maybeText === \"string\" ? maybeText : undefined;\n    }\n\n    const hasContent = typeof thinkingContent === \"string\" && thinkingContent.trim().length > 0;\n    if (!hasContent && !part.signature) {\n      return null;\n    }\n\n    const sanitized: Record<string, unknown> = { type: part.type === \"redacted_thinking\" ? \"redacted_thinking\" : \"thinking\" };\n    if (thinkingContent !== undefined) sanitized.thinking = thinkingContent;\n    if (part.signature !== undefined) sanitized.signature = part.signature;\n    return sanitized;\n  }\n\n  // Reasoning blocks (OpenCode format): { type: \"reasoning\", text, signature }\n  if (part.type === \"reasoning\") {\n    let textContent: unknown = part.text;\n    if (typeof textContent === \"object\" && textContent !== null) {\n      const maybeText = (textContent as any).text;\n      textContent = typeof maybeText === \"string\" ? maybeText : undefined;\n    }\n\n    const hasContent = typeof textContent === \"string\" && textContent.trim().length > 0;\n    if (!hasContent && !part.signature) {\n      return null;\n    }\n\n    const sanitized: Record<string, unknown> = { type: \"reasoning\" };\n    if (textContent !== undefined) sanitized.text = textContent;\n    if (part.signature !== undefined) sanitized.signature = part.signature;\n    return sanitized;\n  }\n\n  // Fallback: strip cache_control recursively.\n  return stripCacheControlRecursively(part) as Record<string, unknown>;\n}\n\nfunction findLastAssistantIndex(contents: any[], roleValue: \"model\" | \"assistant\"): number {\n  for (let i = contents.length - 1; i >= 0; i--) {\n    const content = contents[i];\n    if (content && typeof content === \"object\" && content.role === roleValue) {\n      return i;\n    }\n  }\n  return -1;\n}\n\nfunction filterContentArray(\n  contentArray: any[],\n  sessionId?: string,\n  getCachedSignatureFn?: (sessionId: string, text: string) => string | undefined,\n  isClaudeModel?: boolean,\n  isLastAssistantMessage: boolean = false,\n): any[] {\n  // For Claude models, strip thinking blocks by default for reliability\n  // User can opt-in to keep thinking via config: { \"keep_thinking\": true }\n  if (isClaudeModel && !getKeepThinking()) {\n    return stripAllThinkingBlocks(contentArray);\n  }\n\n  const filtered: any[] = [];\n\n  for (const item of contentArray) {\n    if (!item || typeof item !== \"object\") {\n      filtered.push(item);\n      continue;\n    }\n\n    if (isToolBlock(item)) {\n      if (!isClaudeModel) {\n        filtered.push(item);\n        continue;\n      }\n\n      const sanitizedToolBlock = { ...(item as Record<string, unknown>) };\n      delete (sanitizedToolBlock as any).signature;\n      delete (sanitizedToolBlock as any).thoughtSignature;\n      delete (sanitizedToolBlock as any).thought_signature;\n      delete (sanitizedToolBlock as any).thought;\n      filtered.push(sanitizedToolBlock);\n      continue;\n    }\n\n    const isThinking = isThinkingPart(item);\n    const hasSignature = hasSignatureField(item);\n\n    if (!isThinking && !hasSignature) {\n      filtered.push(item);\n      continue;\n    }\n\n    if (isClaudeModel && (isThinking || hasSignature)) {\n      const thinkingText = getThinkingText(item) || \"\";\n      const sentinelPart = {\n        type: item.type === \"redacted_thinking\" ? \"redacted_thinking\" : \"thinking\",\n        thinking: thinkingText,\n        signature: SKIP_THOUGHT_SIGNATURE,\n      };\n      filtered.push(sentinelPart);\n      continue;\n    }\n\n    // For the LAST assistant message with thinking blocks:\n    // - If signature is OUR cached signature, pass through unchanged\n    // - Otherwise inject sentinel to bypass Antigravity validation\n    // NOTE: We can't trust signatures just because they're >= 50 chars - Claude returns\n    // its own signatures which are long but invalid for Antigravity.\n    if (isLastAssistantMessage && (isThinking || hasSignature)) {\n      // First check if it's our cached signature\n      if (isOurCachedSignature(item, sessionId, getCachedSignatureFn)) {\n        const sanitized = sanitizeThinkingPart(item);\n        if (sanitized) filtered.push(sanitized);\n        continue;\n      }\n      \n      // Not our signature (or no signature) - inject sentinel\n      const thinkingText = getThinkingText(item) || \"\";\n      const existingSignature = item.signature || item.thoughtSignature;\n      const signatureInfo = existingSignature ? `foreign signature (${String(existingSignature).length} chars)` : \"no signature\";\n      log.debug(`Injecting sentinel for last-message thinking block with ${signatureInfo}`);\n      const sentinelPart = {\n        type: item.type || \"thinking\",\n        thinking: thinkingText,\n        signature: SKIP_THOUGHT_SIGNATURE,\n      };\n      filtered.push(sentinelPart);\n      continue;\n    }\n\n    if (isOurCachedSignature(item, sessionId, getCachedSignatureFn)) {\n      const sanitized = sanitizeThinkingPart(item);\n      if (sanitized) filtered.push(sanitized);\n      continue;\n    }\n\n    if (sessionId && getCachedSignatureFn) {\n      const text = getThinkingText(item);\n      if (text) {\n        const cachedSignature = getCachedSignatureFn(sessionId, text);\n        if (cachedSignature && cachedSignature.length >= 50) {\n          const restoredPart = { ...item };\n          if ((item as any).thought === true) {\n            (restoredPart as any).thoughtSignature = cachedSignature;\n          } else {\n            (restoredPart as any).signature = cachedSignature;\n          }\n          const sanitized = sanitizeThinkingPart(restoredPart as Record<string, unknown>);\n          if (sanitized) filtered.push(sanitized);\n          continue;\n        }\n      }\n    }\n  }\n\n  return filtered;\n}\n\n/**\n * Filters thinking blocks from contents unless the signature matches our cache.\n * Attempts to restore signatures from cache for thinking blocks that lack signatures.\n *\n * @param contents - The contents array from the request\n * @param sessionId - Optional session ID for signature cache lookup\n * @param getCachedSignatureFn - Optional function to retrieve cached signatures\n */\nexport function filterUnsignedThinkingBlocks(\n  contents: any[],\n  sessionId?: string,\n  getCachedSignatureFn?: (sessionId: string, text: string) => string | undefined,\n  isClaudeModel?: boolean,\n): any[] {\n  const lastAssistantIdx = findLastAssistantIndex(contents, \"model\");\n\n  return contents.map((content: any, idx: number) => {\n    if (!content || typeof content !== \"object\") {\n      return content;\n    }\n\n    const isLastAssistant = idx === lastAssistantIdx;\n\n    if (Array.isArray((content as any).parts)) {\n      const filteredParts = filterContentArray(\n        (content as any).parts,\n        sessionId,\n        getCachedSignatureFn,\n        isClaudeModel,\n        isLastAssistant,\n      );\n\n      const trimmedParts = (content as any).role === \"model\" && !isClaudeModel\n        ? removeTrailingThinkingBlocks(filteredParts, sessionId, getCachedSignatureFn)\n        : filteredParts;\n\n      return { ...content, parts: trimmedParts };\n    }\n\n    if (Array.isArray((content as any).content)) {\n      const isAssistantRole = (content as any).role === \"assistant\";\n      const isLastAssistantContent = idx === lastAssistantIdx || \n        (isAssistantRole && idx === findLastAssistantIndex(contents, \"assistant\"));\n      \n      const filteredContent = filterContentArray(\n        (content as any).content,\n        sessionId,\n        getCachedSignatureFn,\n        isClaudeModel,\n        isLastAssistantContent,\n      );\n\n      const trimmedContent = isAssistantRole && !isClaudeModel\n        ? removeTrailingThinkingBlocks(filteredContent, sessionId, getCachedSignatureFn)\n        : filteredContent;\n\n      return { ...content, content: trimmedContent };\n    }\n\n    return content;\n  });\n}\n\n/**\n * Filters thinking blocks from Anthropic-style messages[] payloads using cached signatures.\n */\nexport function filterMessagesThinkingBlocks(\n  messages: any[],\n  sessionId?: string,\n  getCachedSignatureFn?: (sessionId: string, text: string) => string | undefined,\n  isClaudeModel?: boolean,\n): any[] {\n  const lastAssistantIdx = findLastAssistantIndex(messages, \"assistant\");\n\n  return messages.map((message: any, idx: number) => {\n    if (!message || typeof message !== \"object\") {\n      return message;\n    }\n\n    if (Array.isArray((message as any).content)) {\n      const isAssistantRole = (message as any).role === \"assistant\";\n      const isLastAssistant = isAssistantRole && idx === lastAssistantIdx;\n      \n      const filteredContent = filterContentArray(\n        (message as any).content,\n        sessionId,\n        getCachedSignatureFn,\n        isClaudeModel,\n        isLastAssistant,\n      );\n\n      const trimmedContent = isAssistantRole && !isClaudeModel\n        ? removeTrailingThinkingBlocks(filteredContent, sessionId, getCachedSignatureFn)\n        : filteredContent;\n\n      return { ...message, content: trimmedContent };\n    }\n\n    return message;\n  });\n}\n\nexport function deepFilterThinkingBlocks(\n  payload: unknown,\n  sessionId?: string,\n  getCachedSignatureFn?: (sessionId: string, text: string) => string | undefined,\n  isClaudeModel?: boolean,\n): unknown {\n  const visited = new WeakSet<object>();\n\n  const walk = (value: unknown): void => {\n    if (!value || typeof value !== \"object\") {\n      return;\n    }\n\n    if (visited.has(value as object)) {\n      return;\n    }\n\n    visited.add(value as object);\n\n    if (Array.isArray(value)) {\n      value.forEach((item) => walk(item));\n      return;\n    }\n\n    const obj = value as Record<string, unknown>;\n\n    if (Array.isArray(obj.contents)) {\n      obj.contents = filterUnsignedThinkingBlocks(\n        obj.contents as any[],\n        sessionId,\n        getCachedSignatureFn,\n        isClaudeModel,\n      );\n    }\n\n    if (Array.isArray(obj.messages)) {\n      obj.messages = filterMessagesThinkingBlocks(\n        obj.messages as any[],\n        sessionId,\n        getCachedSignatureFn,\n        isClaudeModel,\n      );\n    }\n\n    Object.keys(obj).forEach((key) => walk(obj[key]));\n  };\n\n  walk(payload);\n  return payload;\n}\n\n/**\n * Transforms Gemini-style thought parts (thought: true) and Anthropic-style\n * thinking parts (type: \"thinking\") to reasoning format.\n * Claude responses through Antigravity may use candidates structure with Anthropic-style parts.\n */\nfunction transformGeminiCandidate(candidate: any): any {\n  if (!candidate || typeof candidate !== \"object\") {\n    return candidate;\n  }\n\n  const content = candidate.content;\n  if (!content || typeof content !== \"object\" || !Array.isArray(content.parts)) {\n    return candidate;\n  }\n\n  const thinkingTexts: string[] = [];\n  const transformedParts = content.parts.map((part: any) => {\n    if (!part || typeof part !== \"object\") {\n      return part;\n    }\n\n    // Handle Gemini-style: thought: true\n    if (part.thought === true) {\n      const thinkingText = part.text || \"\";\n      thinkingTexts.push(thinkingText);\n      const transformed: Record<string, unknown> = { ...part, type: \"reasoning\" };\n      if (part.cache_control) transformed.cache_control = part.cache_control;\n\n      // Convert signature to providerMetadata format for OpenCode\n      const sig = part.signature || part.thoughtSignature;\n      if (sig) {\n        transformed.providerMetadata = {\n          anthropic: { signature: sig }\n        };\n        delete (transformed as any).signature;\n        delete (transformed as any).thoughtSignature;\n      }\n\n      return transformed;\n    }\n\n    // Handle Anthropic-style in candidates: type: \"thinking\"\n    if (part.type === \"thinking\") {\n      const thinkingText = part.thinking || part.text || \"\";\n      thinkingTexts.push(thinkingText);\n      const transformed: Record<string, unknown> = {\n        ...part,\n        type: \"reasoning\",\n        text: thinkingText,\n        thought: true,\n      };\n      if (part.cache_control) transformed.cache_control = part.cache_control;\n\n      // Convert signature to providerMetadata format for OpenCode\n      const sig = part.signature || part.thoughtSignature;\n      if (sig) {\n        transformed.providerMetadata = {\n          anthropic: { signature: sig }\n        };\n        delete (transformed as any).signature;\n        delete (transformed as any).thoughtSignature;\n      }\n\n      return transformed;\n    }\n\n    // Handle functionCall: parse JSON strings in args and ensure args is always defined\n    // (Ported from LLM-API-Key-Proxy's _extract_tool_call)\n    // Fix: When Claude calls a tool with no parameters, args may be undefined.\n    // opencode expects state.input to be a record, so we must ensure args: {} as fallback.\n    if (part.functionCall) {\n      const parsedArgs = part.functionCall.args\n        ? recursivelyParseJsonStrings(part.functionCall.args)\n        : {};\n      return {\n        ...part,\n        functionCall: {\n          ...part.functionCall,\n          args: parsedArgs,\n        },\n      };\n    }\n\n    // Handle image data (inlineData) - save to disk and return file path\n    if (part.inlineData) {\n      const result = processImageData({\n        mimeType: part.inlineData.mimeType,\n        data: part.inlineData.data,\n      });\n      if (result) {\n        return { text: result };\n      }\n    }\n\n    return part;\n  });\n\n  return {\n    ...candidate,\n    content: { ...content, parts: transformedParts },\n    ...(thinkingTexts.length > 0 ? { reasoning_content: thinkingTexts.join(\"\\n\\n\") } : {}),\n  };\n}\n\n/**\n * Transforms thinking/reasoning content in response parts to OpenCode's expected format.\n * Handles both Gemini-style (thought: true) and Anthropic-style (type: \"thinking\") formats.\n * Also extracts reasoning_content for Anthropic-style responses.\n */\nexport function transformThinkingParts(response: unknown): unknown {\n  if (!response || typeof response !== \"object\") {\n    return response;\n  }\n\n  const resp = response as Record<string, unknown>;\n  const result: Record<string, unknown> = { ...resp };\n  const reasoningTexts: string[] = [];\n\n  // Handle Anthropic-style content array (type: \"thinking\")\n  if (Array.isArray(resp.content)) {\n    const transformedContent: any[] = [];\n    for (const block of resp.content) {\n      if (block && typeof block === \"object\" && (block as any).type === \"thinking\") {\n        const thinkingText = (block as any).thinking || (block as any).text || \"\";\n        reasoningTexts.push(thinkingText);\n        const transformed: Record<string, unknown> = {\n          ...block,\n          type: \"reasoning\",\n          text: thinkingText,\n          thought: true,\n        };\n\n        // Convert signature to providerMetadata format for OpenCode\n        const sig = (block as any).signature || (block as any).thoughtSignature;\n        if (sig) {\n          transformed.providerMetadata = {\n            anthropic: { signature: sig }\n          };\n          delete (transformed as any).signature;\n          delete (transformed as any).thoughtSignature;\n        }\n\n        transformedContent.push(transformed);\n      } else {\n        transformedContent.push(block);\n      }\n    }\n    result.content = transformedContent;\n  }\n\n  // Handle Gemini-style candidates array\n  if (Array.isArray(resp.candidates)) {\n    result.candidates = resp.candidates.map(transformGeminiCandidate);\n  }\n\n  // Add reasoning_content if we found any thinking blocks (for Anthropic-style)\n  if (reasoningTexts.length > 0 && !result.reasoning_content) {\n    result.reasoning_content = reasoningTexts.join(\"\\n\\n\");\n  }\n\n  return result;\n}\n\n/**\n * Ensures thinkingConfig is valid: includeThoughts only allowed when budget > 0.\n */\nexport function normalizeThinkingConfig(config: unknown): ThinkingConfig | undefined {\n  if (!config || typeof config !== \"object\") {\n    return undefined;\n  }\n\n  const record = config as Record<string, unknown>;\n  const budgetRaw = record.thinkingBudget ?? record.thinking_budget;\n  const includeRaw = record.includeThoughts ?? record.include_thoughts;\n\n  const thinkingBudget = typeof budgetRaw === \"number\" && Number.isFinite(budgetRaw) ? budgetRaw : undefined;\n  const includeThoughts = typeof includeRaw === \"boolean\" ? includeRaw : undefined;\n\n  const enableThinking = thinkingBudget !== undefined && thinkingBudget > 0;\n  const finalInclude = enableThinking ? includeThoughts ?? false : false;\n\n  if (!enableThinking && finalInclude === false && thinkingBudget === undefined && includeThoughts === undefined) {\n    return undefined;\n  }\n\n  const normalized: ThinkingConfig = {};\n  if (thinkingBudget !== undefined) {\n    normalized.thinkingBudget = thinkingBudget;\n  }\n  if (finalInclude !== undefined) {\n    normalized.includeThoughts = finalInclude;\n  }\n  return normalized;\n}\n\n/**\n * Parses an Antigravity API body; handles array-wrapped responses the API sometimes returns.\n */\nexport function parseAntigravityApiBody(rawText: string): AntigravityApiBody | null {\n  try {\n    const parsed = JSON.parse(rawText);\n    if (Array.isArray(parsed)) {\n      const firstObject = parsed.find((item: unknown) => typeof item === \"object\" && item !== null);\n      if (firstObject && typeof firstObject === \"object\") {\n        return firstObject as AntigravityApiBody;\n      }\n      return null;\n    }\n\n    if (parsed && typeof parsed === \"object\") {\n      return parsed as AntigravityApiBody;\n    }\n\n    return null;\n  } catch {\n    return null;\n  }\n}\n\n/**\n * Extracts usageMetadata from a response object, guarding types.\n */\nexport function extractUsageMetadata(body: AntigravityApiBody): AntigravityUsageMetadata | null {\n  const usage = (body.response && typeof body.response === \"object\"\n    ? (body.response as { usageMetadata?: unknown }).usageMetadata\n    : undefined) as AntigravityUsageMetadata | undefined;\n\n  if (!usage || typeof usage !== \"object\") {\n    return null;\n  }\n\n  const asRecord = usage as Record<string, unknown>;\n  const toNumber = (value: unknown): number | undefined =>\n    typeof value === \"number\" && Number.isFinite(value) ? value : undefined;\n\n  return {\n    totalTokenCount: toNumber(asRecord.totalTokenCount),\n    promptTokenCount: toNumber(asRecord.promptTokenCount),\n    candidatesTokenCount: toNumber(asRecord.candidatesTokenCount),\n    cachedContentTokenCount: toNumber(asRecord.cachedContentTokenCount),\n    thoughtsTokenCount: toNumber(asRecord.thoughtsTokenCount),\n  };\n}\n\n/**\n * Walks SSE lines to find a usage-bearing response chunk.\n */\nexport function extractUsageFromSsePayload(payload: string): AntigravityUsageMetadata | null {\n  const lines = payload.split(\"\\n\");\n  for (const line of lines) {\n    if (!line.startsWith(\"data:\")) {\n      continue;\n    }\n    const jsonText = line.slice(5).trim();\n    if (!jsonText) {\n      continue;\n    }\n    try {\n      const parsed = JSON.parse(jsonText);\n      if (parsed && typeof parsed === \"object\") {\n        const usage = extractUsageMetadata({ response: (parsed as Record<string, unknown>).response });\n        if (usage) {\n          return usage;\n        }\n      }\n    } catch {\n      continue;\n    }\n  }\n  return null;\n}\n\n/**\n * Enhances 404 errors for Antigravity models with a direct preview-access message.\n */\nexport function rewriteAntigravityPreviewAccessError(\n  body: AntigravityApiBody,\n  status: number,\n  requestedModel?: string,\n): AntigravityApiBody | null {\n  if (!needsPreviewAccessOverride(status, body, requestedModel)) {\n    return null;\n  }\n\n  const error: AntigravityApiError = body.error ?? {};\n  const trimmedMessage = typeof error.message === \"string\" ? error.message.trim() : \"\";\n  const messagePrefix = trimmedMessage.length > 0\n    ? trimmedMessage\n    : \"Antigravity preview features are not enabled for this account.\";\n  const enhancedMessage = `${messagePrefix} Request preview access at ${ANTIGRAVITY_PREVIEW_LINK} before using this model.`;\n\n  return {\n    ...body,\n    error: {\n      ...error,\n      message: enhancedMessage,\n    },\n  };\n}\n\nfunction needsPreviewAccessOverride(\n  status: number,\n  body: AntigravityApiBody,\n  requestedModel?: string,\n): boolean {\n  if (status !== 404) {\n    return false;\n  }\n\n  if (isAntigravityModel(requestedModel)) {\n    return true;\n  }\n\n  const errorMessage = typeof body.error?.message === \"string\" ? body.error.message : \"\";\n  return isAntigravityModel(errorMessage);\n}\n\nfunction isAntigravityModel(target?: string): boolean {\n  if (!target) {\n    return false;\n  }\n\n  // Check for Antigravity models instead of Gemini 3\n  return /antigravity/i.test(target) || /opus/i.test(target) || /claude/i.test(target);\n}\n\n// ============================================================================\n// EMPTY RESPONSE DETECTION (Ported from LLM-API-Key-Proxy)\n// ============================================================================\n\n/**\n * Checks if a JSON response body represents an empty response.\n * \n * Empty responses occur when:\n * - No candidates in Gemini format\n * - No choices in OpenAI format\n * - Candidates/choices exist but have no content\n * \n * @param text - The response body text (should be valid JSON)\n * @returns true if the response is empty\n */\nexport function isEmptyResponseBody(text: string): boolean {\n  if (!text || !text.trim()) {\n    return true;\n  }\n\n  try {\n    const parsed = JSON.parse(text);\n    \n    // Check for empty candidates (Gemini/Antigravity format)\n    if (parsed.candidates !== undefined) {\n      if (!Array.isArray(parsed.candidates) || parsed.candidates.length === 0) {\n        return true;\n      }\n      \n      // Check if first candidate has empty content\n      const firstCandidate = parsed.candidates[0];\n      if (!firstCandidate) {\n        return true;\n      }\n      \n      // Check for empty parts in content\n      const content = firstCandidate.content;\n      if (!content || typeof content !== \"object\") {\n        return true;\n      }\n      \n      const parts = content.parts;\n      if (!Array.isArray(parts) || parts.length === 0) {\n        return true;\n      }\n      \n      // Check if all parts are empty (no text, no functionCall)\n      const hasContent = parts.some((part: any) => {\n        if (!part || typeof part !== \"object\") return false;\n        if (typeof part.text === \"string\" && part.text.length > 0) return true;\n        if (part.functionCall) return true;\n        if (part.thought === true && typeof part.text === \"string\") return true;\n        return false;\n      });\n      \n      if (!hasContent) {\n        return true;\n      }\n    }\n    \n    // Check for empty choices (OpenAI format - shouldn't occur but handle it)\n    if (parsed.choices !== undefined) {\n      if (!Array.isArray(parsed.choices) || parsed.choices.length === 0) {\n        return true;\n      }\n      \n      const firstChoice = parsed.choices[0];\n      if (!firstChoice) {\n        return true;\n      }\n      \n      // Check for empty message/delta\n      const message = firstChoice.message || firstChoice.delta;\n      if (!message) {\n        return true;\n      }\n      \n      // Check if message has content or tool_calls\n      if (!message.content && !message.tool_calls && !message.reasoning_content) {\n        return true;\n      }\n    }\n    \n    // Check response wrapper (Antigravity envelope)\n    if (parsed.response !== undefined) {\n      const response = parsed.response;\n      if (!response || typeof response !== \"object\") {\n        return true;\n      }\n      return isEmptyResponseBody(JSON.stringify(response));\n    }\n    \n    return false;\n  } catch {\n    // JSON parse error - treat as empty\n    return true;\n  }\n}\n\n/**\n * Checks if a streaming SSE response yielded zero meaningful chunks.\n * \n * This is used after consuming a streaming response to determine if retry is needed.\n */\nexport interface StreamingChunkCounter {\n  increment: () => void;\n  getCount: () => number;\n  hasContent: () => boolean;\n}\n\nexport function createStreamingChunkCounter(): StreamingChunkCounter {\n  let count = 0;\n  let hasRealContent = false;\n\n  return {\n    increment: () => {\n      count++;\n    },\n    getCount: () => count,\n    hasContent: () => hasRealContent || count > 0,\n  };\n}\n\n/**\n * Checks if an SSE line contains meaningful content.\n * \n * @param line - A single SSE line (e.g., \"data: {...}\")\n * @returns true if the line contains content worth counting\n */\nexport function isMeaningfulSseLine(line: string): boolean {\n  if (!line.startsWith(\"data: \")) {\n    return false;\n  }\n\n  const data = line.slice(6).trim();\n  \n  if (data === \"[DONE]\") {\n    return false;\n  }\n\n  if (!data) {\n    return false;\n  }\n\n  try {\n    const parsed = JSON.parse(data);\n    \n    // Check for candidates with content\n    if (parsed.candidates && Array.isArray(parsed.candidates)) {\n      for (const candidate of parsed.candidates) {\n        const parts = candidate?.content?.parts;\n        if (Array.isArray(parts) && parts.length > 0) {\n          for (const part of parts) {\n            if (typeof part?.text === \"string\" && part.text.length > 0) return true;\n            if (part?.functionCall) return true;\n          }\n        }\n      }\n    }\n    \n    // Check response wrapper\n    if (parsed.response?.candidates) {\n      return isMeaningfulSseLine(`data: ${JSON.stringify(parsed.response)}`);\n    }\n    \n    return false;\n  } catch {\n    return false;\n  }\n}\n\n// ============================================================================\n// RECURSIVE JSON STRING AUTO-PARSING (Ported from LLM-API-Key-Proxy)\n// ============================================================================\n\n/**\n * Recursively parses JSON strings in nested data structures.\n * \n * This is a port of LLM-API-Key-Proxy's _recursively_parse_json_strings() function.\n * \n * Handles:\n * - JSON-stringified values: {\"files\": \"[{...}]\"} → {\"files\": [{...}]}\n * - Malformed double-encoded JSON (extra trailing chars)\n * - Escaped control characters (\\\\n → \\n, \\\\t → \\t)\n * \n * This is useful because Antigravity sometimes returns JSON-stringified values\n * in tool arguments, which can cause downstream parsing issues.\n * \n * @param obj - The object to recursively parse\n * @param skipParseKeys - Set of keys whose values should NOT be parsed as JSON (preserved as strings)\n * @param currentKey - The current key being processed (internal use)\n * @returns The parsed object with JSON strings expanded\n */\n// Keys whose string values should NOT be parsed as JSON - they contain literal text content\nconst SKIP_PARSE_KEYS = new Set([\n  \"oldString\",\n  \"newString\",\n  \"content\",\n  \"filePath\",\n  \"path\",\n  \"text\",\n  \"code\",\n  \"source\",\n  \"data\",\n  \"body\",\n  \"message\",\n  \"prompt\",\n  \"input\",\n  \"output\",\n  \"result\",\n  \"value\",\n  \"query\",\n  \"pattern\",\n  \"replacement\",\n  \"template\",\n  \"script\",\n  \"command\",\n  \"snippet\",\n]);\n\nexport function recursivelyParseJsonStrings(\n  obj: unknown,\n  skipParseKeys: Set<string> = SKIP_PARSE_KEYS,\n  currentKey?: string,\n): unknown {\n  if (obj === null || obj === undefined) {\n    return obj;\n  }\n\n  if (Array.isArray(obj)) {\n    return obj.map((item) => recursivelyParseJsonStrings(item, skipParseKeys));\n  }\n\n  if (typeof obj === \"object\") {\n    const result: Record<string, unknown> = {};\n    for (const [key, value] of Object.entries(obj)) {\n      result[key] = recursivelyParseJsonStrings(value, skipParseKeys, key);\n    }\n    return result;\n  }\n\n  if (typeof obj !== \"string\") {\n    return obj;\n  }\n\n  if (currentKey && skipParseKeys.has(currentKey)) {\n    return obj;\n  }\n\n  const stripped = obj.trim();\n\n  // Check if string contains control character escape sequences\n  // that need unescaping (\\\\n, \\\\t but NOT \\\\\" or \\\\\\\\)\n  const hasControlCharEscapes = obj.includes(\"\\\\n\") || obj.includes(\"\\\\t\");\n  const hasIntentionalEscapes = obj.includes('\\\\\"') || obj.includes(\"\\\\\\\\\");\n\n  if (hasControlCharEscapes && !hasIntentionalEscapes) {\n    try {\n      // Use JSON.parse with quotes to unescape the string\n      return JSON.parse(`\"${obj}\"`);\n    } catch {\n      // Continue with original processing\n    }\n  }\n\n  // Check if it looks like JSON (starts with { or [)\n  if (stripped && (stripped[0] === \"{\" || stripped[0] === \"[\")) {\n    // Try standard parsing first\n    if (\n      (stripped.startsWith(\"{\") && stripped.endsWith(\"}\")) ||\n      (stripped.startsWith(\"[\") && stripped.endsWith(\"]\"))\n    ) {\n      try {\n        const parsed = JSON.parse(obj);\n        return recursivelyParseJsonStrings(parsed);\n      } catch {\n        // Continue\n      }\n    }\n\n    // Handle malformed JSON: array that doesn't end with ]\n    if (stripped.startsWith(\"[\") && !stripped.endsWith(\"]\")) {\n      try {\n        const lastBracket = stripped.lastIndexOf(\"]\");\n        if (lastBracket > 0) {\n          const cleaned = stripped.slice(0, lastBracket + 1);\n          const parsed = JSON.parse(cleaned);\n          log.debug(\"Auto-corrected malformed JSON array\", {\n            truncatedChars: stripped.length - cleaned.length,\n          });\n          return recursivelyParseJsonStrings(parsed);\n        }\n      } catch {\n        // Continue\n      }\n    }\n\n    // Handle malformed JSON: object that doesn't end with }\n    if (stripped.startsWith(\"{\") && !stripped.endsWith(\"}\")) {\n      try {\n        const lastBrace = stripped.lastIndexOf(\"}\");\n        if (lastBrace > 0) {\n          const cleaned = stripped.slice(0, lastBrace + 1);\n          const parsed = JSON.parse(cleaned);\n          log.debug(\"Auto-corrected malformed JSON object\", {\n            truncatedChars: stripped.length - cleaned.length,\n          });\n          return recursivelyParseJsonStrings(parsed);\n        }\n      } catch {\n        // Continue\n      }\n    }\n  }\n\n  return obj;\n}\n\n// ============================================================================\n// TOOL ID ORPHAN RECOVERY (Ported from LLM-API-Key-Proxy)\n// ============================================================================\n\n/**\n * Groups function calls with their responses, handling ID mismatches.\n * \n * This is a port of LLM-API-Key-Proxy's _fix_tool_response_grouping() function.\n * \n * When context compaction or other processes strip tool responses, the tool call\n * IDs become orphaned. This function attempts to recover by:\n * \n * 1. Pass 1: Match by exact ID (normal case)\n * 2. Pass 2: Match by function name (for ID mismatches)\n * 3. Pass 3: Match \"unknown_function\" orphans or take first available\n * 4. Fallback: Create placeholder responses for missing tool results\n * \n * @param contents - Array of Gemini-style content messages\n * @returns Fixed contents array with matched tool responses\n */\nexport function fixToolResponseGrouping(contents: any[]): any[] {\n  if (!Array.isArray(contents) || contents.length === 0) {\n    return contents;\n  }\n\n  const newContents: any[] = [];\n  \n  // Track pending tool call groups that need responses\n  const pendingGroups: Array<{\n    ids: string[];\n    funcNames: string[];\n    insertAfterIdx: number;\n  }> = [];\n  \n  // Collected orphan responses (by ID)\n  const collectedResponses = new Map<string, any>();\n  \n  for (const content of contents) {\n    const role = content.role;\n    const parts = content.parts || [];\n    \n    // Check if this is a tool response message\n    const responseParts = parts.filter((p: any) => p?.functionResponse);\n    \n    if (responseParts.length > 0) {\n      // Collect responses by ID (skip duplicates)\n      for (const resp of responseParts) {\n        const respId = resp.functionResponse?.id || \"\";\n        if (respId && !collectedResponses.has(respId)) {\n          collectedResponses.set(respId, resp);\n        }\n      }\n      \n      // Try to satisfy the most recent pending group\n      for (let i = pendingGroups.length - 1; i >= 0; i--) {\n        const group = pendingGroups[i]!;\n        if (group.ids.every(id => collectedResponses.has(id))) {\n          // All IDs found - build the response group\n          const groupResponses = group.ids.map(id => {\n            const resp = collectedResponses.get(id);\n            collectedResponses.delete(id);\n            return resp;\n          });\n          newContents.push({ parts: groupResponses, role: \"user\" });\n          pendingGroups.splice(i, 1);\n          break; // Only satisfy one group at a time\n        }\n      }\n      continue; // Don't add the original response message\n    }\n    \n    if (role === \"model\") {\n      // Check for function calls in this model message\n      const funcCalls = parts.filter((p: any) => p?.functionCall);\n      newContents.push(content);\n      \n      if (funcCalls.length > 0) {\n        const callIds = funcCalls\n          .map((fc: any) => fc.functionCall?.id || \"\")\n          .filter(Boolean);\n        const funcNames = funcCalls\n          .map((fc: any) => fc.functionCall?.name || \"\");\n        \n        if (callIds.length > 0) {\n          pendingGroups.push({\n            ids: callIds,\n            funcNames,\n            insertAfterIdx: newContents.length - 1,\n          });\n        }\n      }\n    } else {\n      newContents.push(content);\n    }\n  }\n  \n  // Handle remaining pending groups with orphan recovery\n  // Process in reverse order so insertions don't shift indices\n  pendingGroups.sort((a, b) => b.insertAfterIdx - a.insertAfterIdx);\n  \n  for (const group of pendingGroups) {\n    const groupResponses: any[] = [];\n    \n    for (let i = 0; i < group.ids.length; i++) {\n      const expectedId = group.ids[i]!;\n      const expectedName = group.funcNames[i] || \"\";\n      \n      if (collectedResponses.has(expectedId)) {\n        // Direct ID match - ideal case\n        groupResponses.push(collectedResponses.get(expectedId));\n        collectedResponses.delete(expectedId);\n      } else if (collectedResponses.size > 0) {\n        // Need to find an orphan response\n        let matchedId: string | null = null;\n        \n        // Pass 1: Match by function name\n        for (const [orphanId, orphanResp] of collectedResponses) {\n          const orphanName = orphanResp.functionResponse?.name || \"\";\n          if (orphanName === expectedName) {\n            matchedId = orphanId;\n            break;\n          }\n        }\n        \n        // Pass 2: Match \"unknown_function\" orphans\n        if (!matchedId) {\n          for (const [orphanId, orphanResp] of collectedResponses) {\n            if (orphanResp.functionResponse?.name === \"unknown_function\") {\n              matchedId = orphanId;\n              break;\n            }\n          }\n        }\n        \n        // Pass 3: Take first available\n        if (!matchedId) {\n          matchedId = collectedResponses.keys().next().value ?? null;\n        }\n        \n        if (matchedId) {\n          const orphanResp = collectedResponses.get(matchedId)!;\n          collectedResponses.delete(matchedId);\n          \n          // Fix the ID and name to match expected\n          orphanResp.functionResponse.id = expectedId;\n          if (orphanResp.functionResponse.name === \"unknown_function\" && expectedName) {\n            orphanResp.functionResponse.name = expectedName;\n          }\n          \n          log.debug(\"Auto-repaired tool ID mismatch\", {\n            mappedFrom: matchedId,\n            mappedTo: expectedId,\n            functionName: expectedName,\n          });\n          \n          groupResponses.push(orphanResp);\n        }\n      } else {\n        // No responses available - create placeholder\n        const placeholder = {\n          functionResponse: {\n            name: expectedName || \"unknown_function\",\n            response: {\n              result: {\n                error: \"Tool response was lost during context processing. \" +\n                       \"This is a recovered placeholder.\",\n                recovered: true,\n              },\n            },\n            id: expectedId,\n          },\n        };\n        \n        log.debug(\"Created placeholder response for missing tool\", {\n          id: expectedId,\n          name: expectedName,\n        });\n        \n        groupResponses.push(placeholder);\n      }\n    }\n    \n    if (groupResponses.length > 0) {\n      // Insert at correct position (after the model message that made the calls)\n      newContents.splice(group.insertAfterIdx + 1, 0, {\n        parts: groupResponses,\n        role: \"user\",\n      });\n    }\n  }\n  \n  return newContents;\n}\n\n/**\n * Checks if contents have any tool call/response ID mismatches.\n * \n * @param contents - Array of Gemini-style content messages\n * @returns Object with mismatch details\n */\nexport function detectToolIdMismatches(contents: any[]): {\n  hasMismatches: boolean;\n  expectedIds: string[];\n  foundIds: string[];\n  missingIds: string[];\n  orphanIds: string[];\n} {\n  const expectedIds: string[] = [];\n  const foundIds: string[] = [];\n  \n  for (const content of contents) {\n    const parts = content.parts || [];\n    \n    for (const part of parts) {\n      if (part?.functionCall?.id) {\n        expectedIds.push(part.functionCall.id);\n      }\n      if (part?.functionResponse?.id) {\n        foundIds.push(part.functionResponse.id);\n      }\n    }\n  }\n  \n  const expectedSet = new Set(expectedIds);\n  const foundSet = new Set(foundIds);\n  \n  const missingIds = expectedIds.filter(id => !foundSet.has(id));\n  const orphanIds = foundIds.filter(id => !expectedSet.has(id));\n  \n  return {\n    hasMismatches: missingIds.length > 0 || orphanIds.length > 0,\n    expectedIds,\n    foundIds,\n    missingIds,\n    orphanIds,\n  };\n}\n\n// ============================================================================\n// CLAUDE FORMAT TOOL PAIRING (Defense in Depth)\n// ============================================================================\n\n/**\n * Find orphaned tool_use IDs (tool_use without matching tool_result).\n * Works on Claude format messages.\n */\nexport function findOrphanedToolUseIds(messages: any[]): Set<string> {\n  const toolUseIds = new Set<string>();\n  const toolResultIds = new Set<string>();\n\n  for (const msg of messages) {\n    if (Array.isArray(msg.content)) {\n      for (const block of msg.content) {\n        if (block.type === \"tool_use\" && block.id) {\n          toolUseIds.add(block.id);\n        }\n        if (block.type === \"tool_result\" && block.tool_use_id) {\n          toolResultIds.add(block.tool_use_id);\n        }\n      }\n    }\n  }\n\n  return new Set([...toolUseIds].filter((id) => !toolResultIds.has(id)));\n}\n\n/**\n * Fix orphaned tool_use blocks in Claude format messages.\n * Mirrors fixToolResponseGrouping() but for Claude's messages[] format.\n *\n * Claude format:\n * - assistant message with content[]: { type: 'tool_use', id, name, input }\n * - user message with content[]: { type: 'tool_result', tool_use_id, content }\n *\n * @param messages - Claude format messages array\n * @returns Fixed messages with placeholder tool_results for orphans\n */\nexport function fixClaudeToolPairing(messages: any[]): any[] {\n  if (!Array.isArray(messages) || messages.length === 0) {\n    return messages;\n  }\n\n  // 1. Collect all tool_use IDs from assistant messages\n  const toolUseMap = new Map<string, { name: string; msgIndex: number }>();\n\n  for (let i = 0; i < messages.length; i++) {\n    const msg = messages[i];\n    if (msg.role === \"assistant\" && Array.isArray(msg.content)) {\n      for (const block of msg.content) {\n        if (block.type === \"tool_use\" && block.id) {\n          toolUseMap.set(block.id, { name: block.name || `tool-${toolUseMap.size}`, msgIndex: i });\n        }\n      }\n    }\n  }\n\n  // 2. Collect all tool_result IDs from user messages\n  const toolResultIds = new Set<string>();\n\n  for (const msg of messages) {\n    if (msg.role === \"user\" && Array.isArray(msg.content)) {\n      for (const block of msg.content) {\n        if (block.type === \"tool_result\" && block.tool_use_id) {\n          toolResultIds.add(block.tool_use_id);\n        }\n      }\n    }\n  }\n\n  // 3. Find orphaned tool_use (no matching tool_result)\n  const orphans: Array<{ id: string; name: string; msgIndex: number }> = [];\n\n  for (const [id, info] of toolUseMap) {\n    if (!toolResultIds.has(id)) {\n      orphans.push({ id, ...info });\n    }\n  }\n\n  if (orphans.length === 0) {\n    return messages;\n  }\n\n  // 4. Group orphans by message index (insert after each assistant message)\n  const orphansByMsgIndex = new Map<number, typeof orphans>();\n  for (const orphan of orphans) {\n    const existing = orphansByMsgIndex.get(orphan.msgIndex) || [];\n    existing.push(orphan);\n    orphansByMsgIndex.set(orphan.msgIndex, existing);\n  }\n\n  // 5. Build new messages array with injected tool_results\n  const result: any[] = [];\n\n  for (let i = 0; i < messages.length; i++) {\n    result.push(messages[i]);\n\n    const orphansForMsg = orphansByMsgIndex.get(i);\n    if (orphansForMsg && orphansForMsg.length > 0) {\n      // Check if next message is user with tool_result - if so, merge into it\n      const nextMsg = messages[i + 1];\n      if (nextMsg?.role === \"user\" && Array.isArray(nextMsg.content)) {\n        // Will be handled when we push nextMsg - add to its content\n        const placeholders = orphansForMsg.map((o) => ({\n          type: \"tool_result\",\n          tool_use_id: o.id,\n          content: `[Tool \"${o.name}\" execution was cancelled or failed]`,\n          is_error: true,\n        }));\n        // Prepend placeholders to next message's content\n        nextMsg.content = [...placeholders, ...nextMsg.content];\n      } else {\n        // Inject new user message with placeholder tool_results\n        result.push({\n          role: \"user\",\n          content: orphansForMsg.map((o) => ({\n            type: \"tool_result\",\n            tool_use_id: o.id,\n            content: `[Tool \"${o.name}\" execution was cancelled or failed]`,\n            is_error: true,\n          })),\n        });\n      }\n    }\n  }\n\n  return result;\n}\n\n/**\n * Nuclear option: Remove orphaned tool_use blocks entirely.\n * Called when fixClaudeToolPairing() fails to pair all tools.\n */\nfunction removeOrphanedToolUse(messages: any[], orphanIds: Set<string>): any[] {\n  return messages\n    .map((msg) => {\n      if (msg.role === \"assistant\" && Array.isArray(msg.content)) {\n        return {\n          ...msg,\n          content: msg.content.filter(\n            (block: any) => block.type !== \"tool_use\" || !orphanIds.has(block.id)\n          ),\n        };\n      }\n      return msg;\n    })\n    .filter(\n      (msg) =>\n        // Remove empty assistant messages\n        !(msg.role === \"assistant\" && Array.isArray(msg.content) && msg.content.length === 0)\n    );\n}\n\n/**\n * Validate and fix tool pairing with fallback nuclear option.\n * Defense in depth: tries gentle fix first, then nuclear removal.\n */\nexport function validateAndFixClaudeToolPairing(messages: any[]): any[] {\n  if (!Array.isArray(messages) || messages.length === 0) {\n    return messages;\n  }\n\n  // First: Try gentle fix (inject placeholder tool_results)\n  let fixed = fixClaudeToolPairing(messages);\n\n  // Second: Validate - find any remaining orphans\n  const orphanIds = findOrphanedToolUseIds(fixed);\n\n  if (orphanIds.size === 0) {\n    return fixed;\n  }\n\n  // Third: Nuclear option - remove orphaned tool_use entirely\n  // This should rarely happen, but provides defense in depth\n  console.warn(\"[antigravity] fixClaudeToolPairing left orphans, applying nuclear option\", {\n    orphanIds: [...orphanIds],\n  });\n\n  return removeOrphanedToolUse(fixed, orphanIds);\n}\n\n// ============================================================================\n// TOOL HALLUCINATION PREVENTION (Ported from LLM-API-Key-Proxy)\n// ============================================================================\n\n/**\n * Formats a type hint for a property schema.\n * Port of LLM-API-Key-Proxy's _format_type_hint()\n */\nfunction formatTypeHint(propData: Record<string, unknown>, depth = 0): string {\n  const type = propData.type as string ?? \"unknown\";\n\n  // Handle enum values\n  if (propData.enum && Array.isArray(propData.enum)) {\n    const enumVals = propData.enum as unknown[];\n    if (enumVals.length <= 5) {\n      return `string ENUM[${enumVals.map(v => JSON.stringify(v)).join(\", \")}]`;\n    }\n    return `string ENUM[${enumVals.length} options]`;\n  }\n\n  // Handle const values\n  if (propData.const !== undefined) {\n    return `string CONST=${JSON.stringify(propData.const)}`;\n  }\n\n  if (type === \"array\") {\n    const items = propData.items as Record<string, unknown> | undefined;\n    if (items && typeof items === \"object\") {\n      const itemType = items.type as string ?? \"unknown\";\n      if (itemType === \"object\") {\n        const nestedProps = items.properties as Record<string, unknown> | undefined;\n        const nestedReq = items.required as string[] | undefined ?? [];\n        if (nestedProps && depth < 1) {\n          const nestedList = Object.entries(nestedProps).map(([n, d]) => {\n            const t = (d as Record<string, unknown>).type as string ?? \"unknown\";\n            const req = nestedReq.includes(n) ? \" REQUIRED\" : \"\";\n            return `${n}: ${t}${req}`;\n          });\n          return `ARRAY_OF_OBJECTS[${nestedList.join(\", \")}]`;\n        }\n        return \"ARRAY_OF_OBJECTS\";\n      }\n      return `ARRAY_OF_${itemType.toUpperCase()}`;\n    }\n    return \"ARRAY\";\n  }\n\n  if (type === \"object\") {\n    const nestedProps = propData.properties as Record<string, unknown> | undefined;\n    const nestedReq = propData.required as string[] | undefined ?? [];\n    if (nestedProps && depth < 1) {\n      const nestedList = Object.entries(nestedProps).map(([n, d]) => {\n        const t = (d as Record<string, unknown>).type as string ?? \"unknown\";\n        const req = nestedReq.includes(n) ? \" REQUIRED\" : \"\";\n        return `${n}: ${t}${req}`;\n      });\n      return `object{${nestedList.join(\", \")}}`;\n    }\n  }\n\n  return type;\n}\n\n/**\n * Injects parameter signatures into tool descriptions.\n * Port of LLM-API-Key-Proxy's _inject_signature_into_descriptions()\n * \n * This helps prevent tool hallucination by explicitly listing parameters\n * in the description, making it harder for the model to hallucinate\n * parameters from its training data.\n * \n * @param tools - Array of tool definitions (Gemini format)\n * @param promptTemplate - Template for the signature (default: \"\\\\n\\\\nSTRICT PARAMETERS: {params}.\")\n * @returns Modified tools array with signatures injected\n */\nexport function injectParameterSignatures(\n  tools: any[],\n  promptTemplate = \"\\n\\n⚠️ STRICT PARAMETERS: {params}.\",\n): any[] {\n  if (!tools || !Array.isArray(tools)) return tools;\n\n  return tools.map((tool) => {\n    const declarations = tool.functionDeclarations;\n    if (!Array.isArray(declarations)) return tool;\n\n    const newDeclarations = declarations.map((decl: any) => {\n      // Skip if signature already injected (avoids duplicate injection)\n      if (decl.description?.includes(\"STRICT PARAMETERS:\")) {\n        return decl;\n      }\n\n      const schema = decl.parameters || decl.parametersJsonSchema;\n      if (!schema) return decl;\n\n      const required = schema.required as string[] ?? [];\n      const properties = schema.properties as Record<string, unknown> ?? {};\n\n      if (Object.keys(properties).length === 0) return decl;\n\n      const paramList = Object.entries(properties).map(([propName, propData]) => {\n        const typeHint = formatTypeHint(propData as Record<string, unknown>);\n        const isRequired = required.includes(propName);\n        return `${propName} (${typeHint}${isRequired ? \", REQUIRED\" : \"\"})`;\n      });\n\n      const sigStr = promptTemplate.replace(\"{params}\", paramList.join(\", \"));\n      \n      return {\n        ...decl,\n        description: (decl.description || \"\") + sigStr,\n      };\n    });\n\n    return { ...tool, functionDeclarations: newDeclarations };\n  });\n}\n\n/**\n * Injects a tool hardening system instruction into the request payload.\n * Port of LLM-API-Key-Proxy's _inject_tool_hardening_instruction()\n * \n * @param payload - The Gemini request payload\n * @param instructionText - The instruction text to inject\n */\nexport function injectToolHardeningInstruction(\n  payload: Record<string, unknown>,\n  instructionText: string,\n): void {\n  if (!instructionText) return;\n\n  // Skip if instruction already present (avoids duplicate injection)\n  const existing = payload.systemInstruction as Record<string, unknown> | undefined;\n  if (existing && typeof existing === \"object\" && \"parts\" in existing) {\n    const parts = existing.parts as Array<{ text?: string }>;\n    if (Array.isArray(parts) && parts.some(p => p.text?.includes(\"CRITICAL TOOL USAGE INSTRUCTIONS\"))) {\n      return;\n    }\n  }\n\n  const instructionPart = { text: instructionText };\n\n  if (payload.systemInstruction) {\n    if (existing && typeof existing === \"object\" && \"parts\" in existing) {\n      const parts = existing.parts as unknown[];\n      if (Array.isArray(parts)) {\n        parts.unshift(instructionPart);\n      }\n    } else if (typeof existing === \"string\") {\n      payload.systemInstruction = {\n        role: \"user\",\n        parts: [instructionPart, { text: existing }],\n      };\n    } else {\n      payload.systemInstruction = {\n        role: \"user\",\n        parts: [instructionPart],\n      };\n    }\n  } else {\n    payload.systemInstruction = {\n      role: \"user\",\n      parts: [instructionPart],\n    };\n  }\n}\n\n// ============================================================================\n// TOOL PROCESSING FOR WRAPPED REQUESTS\n// Shared logic for assigning tool IDs and fixing tool pairing\n// ============================================================================\n\n/**\n * Assigns IDs to functionCall parts and returns the pending call IDs by name.\n * This is the first pass of tool ID assignment.\n * \n * @param contents - Gemini-style contents array\n * @returns Object with modified contents and pending call IDs map\n */\nexport function assignToolIdsToContents(\n  contents: any[]\n): { contents: any[]; pendingCallIdsByName: Map<string, string[]>; toolCallCounter: number } {\n  if (!Array.isArray(contents)) {\n    return { contents, pendingCallIdsByName: new Map(), toolCallCounter: 0 };\n  }\n\n  let toolCallCounter = 0;\n  const pendingCallIdsByName = new Map<string, string[]>();\n\n  const newContents = contents.map((content: any) => {\n    if (!content || !Array.isArray(content.parts)) {\n      return content;\n    }\n\n    const newParts = content.parts.map((part: any) => {\n      if (part && typeof part === \"object\" && part.functionCall) {\n        const call = { ...part.functionCall };\n        if (!call.id) {\n          call.id = `tool-call-${++toolCallCounter}`;\n        }\n        const nameKey = typeof call.name === \"string\" ? call.name : `tool-${toolCallCounter}`;\n        const queue = pendingCallIdsByName.get(nameKey) || [];\n        queue.push(call.id);\n        pendingCallIdsByName.set(nameKey, queue);\n        return { ...part, functionCall: call };\n      }\n      return part;\n    });\n\n    return { ...content, parts: newParts };\n  });\n\n  return { contents: newContents, pendingCallIdsByName, toolCallCounter };\n}\n\n/**\n * Matches functionResponse IDs to their corresponding functionCall IDs.\n * This is the second pass of tool ID assignment.\n * \n * @param contents - Gemini-style contents array\n * @param pendingCallIdsByName - Map of function names to pending call IDs\n * @returns Modified contents with matched response IDs\n */\nexport function matchResponseIdsToContents(\n  contents: any[],\n  pendingCallIdsByName: Map<string, string[]>\n): any[] {\n  if (!Array.isArray(contents)) {\n    return contents;\n  }\n\n  return contents.map((content: any) => {\n    if (!content || !Array.isArray(content.parts)) {\n      return content;\n    }\n\n    const newParts = content.parts.map((part: any) => {\n      if (part && typeof part === \"object\" && part.functionResponse) {\n        const resp = { ...part.functionResponse };\n        if (!resp.id && typeof resp.name === \"string\") {\n          const queue = pendingCallIdsByName.get(resp.name);\n          if (queue && queue.length > 0) {\n            resp.id = queue.shift();\n            pendingCallIdsByName.set(resp.name, queue);\n          }\n        }\n        return { ...part, functionResponse: resp };\n      }\n      return part;\n    });\n\n    return { ...content, parts: newParts };\n  });\n}\n\n/**\n * Applies all tool fixes to a request payload for Claude models.\n * This includes:\n * 1. Tool ID assignment for functionCalls\n * 2. Response ID matching for functionResponses\n * 3. Orphan recovery via fixToolResponseGrouping\n * 4. Claude format pairing fix via validateAndFixClaudeToolPairing\n * \n * @param payload - Request payload object\n * @param isClaude - Whether this is a Claude model request\n * @returns Object with fix applied status\n */\nexport function applyToolPairingFixes(\n  payload: Record<string, unknown>,\n  isClaude: boolean\n): { contentsFixed: boolean; messagesFixed: boolean } {\n  let contentsFixed = false;\n  let messagesFixed = false;\n\n  if (!isClaude) {\n    return { contentsFixed, messagesFixed };\n  }\n\n  // Fix Gemini format (contents[])\n  if (Array.isArray(payload.contents)) {\n    // First pass: assign IDs to functionCalls\n    const { contents: contentsWithIds, pendingCallIdsByName } = assignToolIdsToContents(\n      payload.contents as any[]\n    );\n\n    // Second pass: match functionResponse IDs\n    const contentsWithMatchedIds = matchResponseIdsToContents(contentsWithIds, pendingCallIdsByName);\n\n    // Third pass: fix orphan recovery\n    payload.contents = fixToolResponseGrouping(contentsWithMatchedIds);\n    contentsFixed = true;\n\n    log.debug(\"Applied tool pairing fixes to contents[]\", {\n      originalLength: (payload.contents as any[]).length,\n    });\n  }\n\n  // Fix Claude format (messages[])\n  if (Array.isArray(payload.messages)) {\n    payload.messages = validateAndFixClaudeToolPairing(payload.messages as any[]);\n    messagesFixed = true;\n\n    log.debug(\"Applied tool pairing fixes to messages[]\", {\n      originalLength: (payload.messages as any[]).length,\n    });\n  }\n\n  return { contentsFixed, messagesFixed };\n}\n\n// ============================================================================\n// SYNTHETIC CLAUDE SSE RESPONSE\n// Used to return error messages as \"successful\" responses to avoid locking\n// the OpenCode session when unrecoverable errors (like 400 Prompt Too Long) occur.\n// ============================================================================\n\n/**\n * Creates a synthetic Claude SSE streaming response with error content.\n * \n * When returning HTTP 400/500 errors to OpenCode, the session becomes locked\n * and the user cannot use /compact or other commands. This function creates\n * a fake \"successful\" SSE response (200 OK) with the error message as text content,\n * allowing the user to continue using the session.\n * \n * @param errorMessage - The error message to include in the response\n * @param requestedModel - The model that was requested\n * @returns A Response object with synthetic SSE stream\n */\nexport function createSyntheticErrorResponse(\n  errorMessage: string,\n  requestedModel: string = \"unknown\",\n): Response {\n  // Generate a unique message ID\n  const messageId = `msg_synthetic_${Date.now()}`;\n  \n  // Build Claude SSE events that represent a complete message with error text\n  const events: string[] = [];\n  \n  // 1. message_start event\n  events.push(`event: message_start\ndata: ${JSON.stringify({\n    type: \"message_start\",\n    message: {\n      id: messageId,\n      type: \"message\",\n      role: \"assistant\",\n      content: [],\n      model: requestedModel,\n      stop_reason: null,\n      stop_sequence: null,\n      usage: { input_tokens: 0, output_tokens: 0 },\n    },\n  })}\n\n`);\n\n  // 2. content_block_start event\n  events.push(`event: content_block_start\ndata: ${JSON.stringify({\n    type: \"content_block_start\",\n    index: 0,\n    content_block: { type: \"text\", text: \"\" },\n  })}\n\n`);\n\n  // 3. content_block_delta event with the error message\n  events.push(`event: content_block_delta\ndata: ${JSON.stringify({\n    type: \"content_block_delta\",\n    index: 0,\n    delta: { type: \"text_delta\", text: errorMessage },\n  })}\n\n`);\n\n  // 4. content_block_stop event\n  events.push(`event: content_block_stop\ndata: ${JSON.stringify({\n    type: \"content_block_stop\",\n    index: 0,\n  })}\n\n`);\n\n  // 5. message_delta event (end_turn)\n  events.push(`event: message_delta\ndata: ${JSON.stringify({\n    type: \"message_delta\",\n    delta: { stop_reason: \"end_turn\", stop_sequence: null },\n    usage: { output_tokens: Math.ceil(errorMessage.length / 4) },\n  })}\n\n`);\n\n  // 6. message_stop event\n  events.push(`event: message_stop\ndata: ${JSON.stringify({ type: \"message_stop\" })}\n\n`);\n\n  const body = events.join(\"\");\n\n  return new Response(body, {\n    status: 200,\n    headers: {\n      \"Content-Type\": \"text/event-stream\",\n      \"Cache-Control\": \"no-cache\",\n      \"Connection\": \"keep-alive\",\n      \"X-Antigravity-Synthetic\": \"true\",\n      \"X-Antigravity-Error-Type\": \"prompt_too_long\",\n    },\n  });\n}\n"
  },
  {
    "path": "src/plugin/request.test.ts",
    "content": "import { describe, it, expect, vi } from \"vitest\";\nimport {\n  prepareAntigravityRequest,\n  transformAntigravityResponse,\n  getPluginSessionId,\n  isGenerativeLanguageRequest,\n  __testExports,\n} from \"./request\";\nimport { DEFAULT_CONFIG } from \"./config\";\nimport { initializeDebug } from \"./debug\";\nimport { SKIP_THOUGHT_SIGNATURE } from \"../constants\";\nimport * as config from \"./config\";\nimport type { SignatureStore, ThoughtBuffer, StreamingCallbacks, StreamingOptions } from \"./core/streaming/types\";\n\nconst {\n  buildSignatureSessionKey,\n  hashConversationSeed,\n  extractTextFromContent,\n  extractConversationSeedFromMessages,\n  extractConversationSeedFromContents,\n  resolveProjectKey,\n  isGeminiToolUsePart,\n  isGeminiThinkingPart,\n  ensureThoughtSignature,\n  hasSignedThinkingPart,\n  hasToolUseInContents,\n  hasSignedThinkingInContents,\n  hasToolUseInMessages,\n  hasSignedThinkingInMessages,\n  generateSyntheticProjectId,\n  MIN_SIGNATURE_LENGTH,\n  transformStreamingPayload,\n  createStreamingTransformer,\n  transformSseLine,\n} = __testExports;\n\nfunction createMockSignatureStore(): SignatureStore {\n  const store = new Map<string, { text: string; signature: string }>();\n  return {\n    get: (key: string) => store.get(key),\n    set: (key: string, value: { text: string; signature: string }) => store.set(key, value),\n    has: (key: string) => store.has(key),\n    delete: (key: string) => store.delete(key),\n  };\n}\n\nfunction createMockThoughtBuffer(): ThoughtBuffer {\n  const buffer = new Map<number, string>();\n  return {\n    get: (idx: number) => buffer.get(idx),\n    set: (idx: number, text: string) => buffer.set(idx, text),\n    clear: () => buffer.clear(),\n  };\n}\n\nconst defaultCallbacks: StreamingCallbacks = {};\nconst defaultOptions: StreamingOptions = {};\nconst defaultDebugState = { injected: false };\n\nfunction withKeepThinking<T>(enabled: boolean, fn: () => T): T {\n  const keepThinkingSpy = vi.spyOn(config, \"getKeepThinking\").mockReturnValue(enabled);\n  try {\n    return fn();\n  } finally {\n    keepThinkingSpy.mockRestore();\n  }\n}\n\ndescribe(\"request.ts\", () => {\n  describe(\"getPluginSessionId\", () => {\n    it(\"returns consistent session ID across calls\", () => {\n      const id1 = getPluginSessionId();\n      const id2 = getPluginSessionId();\n      expect(id1).toBe(id2);\n      expect(id1).toBeTruthy();\n    });\n  });\n\n  describe(\"isGenerativeLanguageRequest\", () => {\n    it(\"returns true for generativelanguage.googleapis.com URLs\", () => {\n      expect(isGenerativeLanguageRequest(\"https://generativelanguage.googleapis.com/v1/models\")).toBe(true);\n    });\n\n    it(\"returns false for other URLs\", () => {\n      expect(isGenerativeLanguageRequest(\"https://api.anthropic.com/v1/messages\")).toBe(false);\n    });\n\n    it(\"returns false for non-string inputs\", () => {\n      expect(isGenerativeLanguageRequest({} as any)).toBe(false);\n      expect(isGenerativeLanguageRequest(new Request(\"https://example.com\"))).toBe(false);\n    });\n  });\n\n  describe(\"buildSignatureSessionKey\", () => {\n    it(\"builds key from sessionId, model, project, and conversation\", () => {\n      const key = buildSignatureSessionKey(\"session-1\", \"claude-3\", \"conv-456\", \"proj-123\");\n      expect(key).toBe(\"session-1:claude-3:proj-123:conv-456\");\n    });\n\n    it(\"uses defaults for missing optional params\", () => {\n      expect(buildSignatureSessionKey(\"s1\", undefined, undefined, undefined)).toBe(\"s1:unknown:default:default\");\n      expect(buildSignatureSessionKey(\"s1\", \"model\", undefined, undefined)).toBe(\"s1:model:default:default\");\n    });\n\n    it(\"handles empty strings as defaults\", () => {\n      expect(buildSignatureSessionKey(\"s1\", \"\", \"\", \"\")).toBe(\"s1:unknown:default:default\");\n    });\n  });\n\n  describe(\"hashConversationSeed\", () => {\n    it(\"returns consistent hash for same input\", () => {\n      const hash1 = hashConversationSeed(\"test-seed\");\n      const hash2 = hashConversationSeed(\"test-seed\");\n      expect(hash1).toBe(hash2);\n    });\n\n    it(\"returns different hash for different inputs\", () => {\n      const hash1 = hashConversationSeed(\"seed-1\");\n      const hash2 = hashConversationSeed(\"seed-2\");\n      expect(hash1).not.toBe(hash2);\n    });\n\n    it(\"handles empty string\", () => {\n      const hash = hashConversationSeed(\"\");\n      expect(hash).toBeTruthy();\n    });\n  });\n\n  describe(\"extractTextFromContent\", () => {\n    it(\"extracts text from string content\", () => {\n      expect(extractTextFromContent(\"hello world\")).toBe(\"hello world\");\n    });\n\n    it(\"extracts first text from content array with text blocks\", () => {\n      const content = [\n        { type: \"text\", text: \"hello\" },\n        { type: \"text\", text: \"world\" },\n      ];\n      expect(extractTextFromContent(content)).toBe(\"hello\");\n    });\n\n    it(\"returns empty string for non-text blocks\", () => {\n      const content = [{ type: \"image\", source: {} }];\n      expect(extractTextFromContent(content)).toBe(\"\");\n    });\n\n    it(\"returns first text block only (not concatenated)\", () => {\n      const content = [\n        { type: \"text\", text: \"before\" },\n        { type: \"image\", source: {} },\n        { type: \"text\", text: \"after\" },\n      ];\n      expect(extractTextFromContent(content)).toBe(\"before\");\n    });\n\n    it(\"returns empty string for null/undefined\", () => {\n      expect(extractTextFromContent(null)).toBe(\"\");\n      expect(extractTextFromContent(undefined)).toBe(\"\");\n    });\n  });\n\n  describe(\"extractConversationSeedFromMessages\", () => {\n    it(\"extracts seed from first user message\", () => {\n      const messages = [\n        { role: \"user\", content: \"first message\" },\n        { role: \"assistant\", content: \"response\" },\n      ];\n      const seed = extractConversationSeedFromMessages(messages);\n      expect(seed).toContain(\"first message\");\n    });\n\n    it(\"returns empty string when no user messages\", () => {\n      const messages = [{ role: \"assistant\", content: \"response\" }];\n      expect(extractConversationSeedFromMessages(messages)).toBe(\"\");\n    });\n\n    it(\"handles empty messages array\", () => {\n      expect(extractConversationSeedFromMessages([])).toBe(\"\");\n    });\n  });\n\n  describe(\"extractConversationSeedFromContents\", () => {\n    it(\"extracts seed from first user content\", () => {\n      const contents = [\n        { role: \"user\", parts: [{ text: \"hello\" }] },\n        { role: \"model\", parts: [{ text: \"hi\" }] },\n      ];\n      const seed = extractConversationSeedFromContents(contents);\n      expect(seed).toContain(\"hello\");\n    });\n\n    it(\"returns empty string when no user content\", () => {\n      const contents = [{ role: \"model\", parts: [{ text: \"hi\" }] }];\n      expect(extractConversationSeedFromContents(contents)).toBe(\"\");\n    });\n  });\n\n  describe(\"resolveProjectKey\", () => {\n    it(\"returns candidate if it is a string\", () => {\n      expect(resolveProjectKey(\"my-project\")).toBe(\"my-project\");\n    });\n\n    it(\"returns fallback if candidate is not a string\", () => {\n      expect(resolveProjectKey(null, \"fallback\")).toBe(\"fallback\");\n      expect(resolveProjectKey(undefined, \"fallback\")).toBe(\"fallback\");\n      expect(resolveProjectKey({}, \"fallback\")).toBe(\"fallback\");\n    });\n\n    it(\"returns undefined if no valid candidate or fallback\", () => {\n      expect(resolveProjectKey(null)).toBeUndefined();\n      expect(resolveProjectKey(undefined)).toBeUndefined();\n    });\n  });\n\n  describe(\"isGeminiToolUsePart\", () => {\n    it(\"returns true for functionCall parts\", () => {\n      expect(isGeminiToolUsePart({ functionCall: { name: \"test\" } })).toBe(true);\n    });\n\n    it(\"returns false for non-functionCall parts\", () => {\n      expect(isGeminiToolUsePart({ text: \"hello\" })).toBe(false);\n      expect(isGeminiToolUsePart({ thought: true })).toBe(false);\n    });\n\n    it(\"returns false for null/undefined\", () => {\n      expect(isGeminiToolUsePart(null)).toBe(false);\n      expect(isGeminiToolUsePart(undefined)).toBe(false);\n    });\n  });\n\n  describe(\"isGeminiThinkingPart\", () => {\n    it(\"returns true for thought:true parts\", () => {\n      expect(isGeminiThinkingPart({ thought: true, text: \"thinking...\" })).toBe(true);\n    });\n\n    it(\"returns false for thought:false parts\", () => {\n      expect(isGeminiThinkingPart({ thought: false, text: \"not thinking\" })).toBe(false);\n    });\n\n    it(\"returns false for parts without thought property\", () => {\n      expect(isGeminiThinkingPart({ text: \"hello\" })).toBe(false);\n    });\n  });\n\n  describe(\"ensureThoughtSignature\", () => {\n    it(\"adds sentinel signature when no cached signature exists\", () => {\n      const part = { thought: true, text: \"thinking...\" };\n      const result = ensureThoughtSignature(part, \"no-cache-session\");\n      // Now uses sentinel fallback to prevent API rejection\n      expect(result.thoughtSignature).toBe(\"skip_thought_signature_validator\");\n    });\n\n    it(\"replaces untrusted thoughtSignature with sentinel\", () => {\n      const existingSignature = \"a\".repeat(MIN_SIGNATURE_LENGTH + 10);\n      const part = { thought: true, text: \"thinking...\", thoughtSignature: existingSignature };\n      const result = ensureThoughtSignature(part, \"session-key\");\n      expect(result.thoughtSignature).toBe(\"skip_thought_signature_validator\");\n    });\n\n    it(\"does not modify non-thinking parts\", () => {\n      const part = { text: \"regular text\" };\n      const result = ensureThoughtSignature(part, \"session-key\");\n      expect(result.thoughtSignature).toBeUndefined();\n    });\n\n    it(\"returns null/undefined inputs unchanged\", () => {\n      expect(ensureThoughtSignature(null, \"key\")).toBeNull();\n      expect(ensureThoughtSignature(undefined, \"key\")).toBeUndefined();\n    });\n\n    it(\"returns non-object inputs unchanged\", () => {\n      expect(ensureThoughtSignature(\"string\", \"key\")).toBe(\"string\");\n      expect(ensureThoughtSignature(123, \"key\")).toBe(123);\n    });\n  });\n\n  describe(\"hasSignedThinkingPart\", () => {\n    it(\"returns true for part with valid thoughtSignature\", () => {\n      const part = { thought: true, thoughtSignature: \"a\".repeat(MIN_SIGNATURE_LENGTH) };\n      expect(hasSignedThinkingPart(part)).toBe(true);\n    });\n\n    it(\"returns true for type:thinking with valid signature field\", () => {\n      const part = { type: \"thinking\", thinking: \"...\", signature: \"a\".repeat(MIN_SIGNATURE_LENGTH) };\n      expect(hasSignedThinkingPart(part)).toBe(true);\n    });\n\n    it(\"returns true for type:reasoning with valid signature field\", () => {\n      const part = { type: \"reasoning\", signature: \"a\".repeat(MIN_SIGNATURE_LENGTH) };\n      expect(hasSignedThinkingPart(part)).toBe(true);\n    });\n\n    it(\"returns false for part with short signature\", () => {\n      const part = { thought: true, thoughtSignature: \"short\" };\n      expect(hasSignedThinkingPart(part)).toBe(false);\n    });\n\n    it(\"returns false for part without signature\", () => {\n      const part = { thought: true, text: \"no signature\" };\n      expect(hasSignedThinkingPart(part)).toBe(false);\n    });\n  });\n\n  describe(\"hasToolUseInContents\", () => {\n    it(\"returns true when contents have functionCall\", () => {\n      const contents = [\n        { role: \"model\", parts: [{ functionCall: { name: \"test\" } }] },\n      ];\n      expect(hasToolUseInContents(contents)).toBe(true);\n    });\n\n    it(\"returns false when no functionCall present\", () => {\n      const contents = [\n        { role: \"model\", parts: [{ text: \"hello\" }] },\n      ];\n      expect(hasToolUseInContents(contents)).toBe(false);\n    });\n\n    it(\"handles empty contents\", () => {\n      expect(hasToolUseInContents([])).toBe(false);\n    });\n  });\n\n  describe(\"hasSignedThinkingInContents\", () => {\n    it(\"returns true when contents have signed thinking\", () => {\n      const contents = [\n        {\n          role: \"model\",\n          parts: [{ thought: true, thoughtSignature: \"a\".repeat(MIN_SIGNATURE_LENGTH) }],\n        },\n      ];\n      expect(hasSignedThinkingInContents(contents)).toBe(true);\n    });\n\n    it(\"returns false when no signed thinking present\", () => {\n      const contents = [\n        { role: \"model\", parts: [{ thought: true, text: \"unsigned\" }] },\n      ];\n      expect(hasSignedThinkingInContents(contents)).toBe(false);\n    });\n  });\n\n  describe(\"hasToolUseInMessages\", () => {\n    it(\"returns true when messages have tool_use blocks\", () => {\n      const messages = [\n        { role: \"assistant\", content: [{ type: \"tool_use\", id: \"123\", name: \"test\" }] },\n      ];\n      expect(hasToolUseInMessages(messages)).toBe(true);\n    });\n\n    it(\"returns false when no tool_use blocks\", () => {\n      const messages = [\n        { role: \"assistant\", content: [{ type: \"text\", text: \"hello\" }] },\n      ];\n      expect(hasToolUseInMessages(messages)).toBe(false);\n    });\n\n    it(\"handles string content\", () => {\n      const messages = [{ role: \"assistant\", content: \"just text\" }];\n      expect(hasToolUseInMessages(messages)).toBe(false);\n    });\n  });\n\n  describe(\"hasSignedThinkingInMessages\", () => {\n    it(\"returns true when messages have signed thinking blocks\", () => {\n      const messages = [\n        {\n          role: \"assistant\",\n          content: [{ type: \"thinking\", thinking: \"...\", signature: \"a\".repeat(MIN_SIGNATURE_LENGTH) }],\n        },\n      ];\n      expect(hasSignedThinkingInMessages(messages)).toBe(true);\n    });\n\n    it(\"returns false when thinking blocks are unsigned\", () => {\n      const messages = [\n        { role: \"assistant\", content: [{ type: \"thinking\", thinking: \"no sig\" }] },\n      ];\n      expect(hasSignedThinkingInMessages(messages)).toBe(false);\n    });\n  });\n\n  describe(\"generateSyntheticProjectId\", () => {\n    it(\"generates a string in expected format\", () => {\n      const id = generateSyntheticProjectId();\n      expect(id).toMatch(/^[a-z]+-[a-z]+-[a-z0-9]{5}$/);\n    });\n\n    it(\"generates unique IDs on each call\", () => {\n      const ids = new Set<string>();\n      for (let i = 0; i < 10; i++) {\n        ids.add(generateSyntheticProjectId());\n      }\n      expect(ids.size).toBe(10);\n    });\n  });\n\n  describe(\"MIN_SIGNATURE_LENGTH\", () => {\n    it(\"is 50\", () => {\n      expect(MIN_SIGNATURE_LENGTH).toBe(50);\n    });\n  });\n\n  describe(\"transformSseLine\", () => {\n    const callTransformSseLine = (line: string) => {\n      const store = createMockSignatureStore();\n      const buffer = createMockThoughtBuffer();\n      const sentBuffer = createMockThoughtBuffer();\n      return transformSseLine(line, store, buffer, sentBuffer, defaultCallbacks, defaultOptions, { ...defaultDebugState });\n    };\n\n    it(\"returns empty lines unchanged\", () => {\n      expect(callTransformSseLine(\"\")).toBe(\"\");\n      expect(callTransformSseLine(\"   \")).toBe(\"   \");\n    });\n\n    it(\"returns non-data lines unchanged\", () => {\n      expect(callTransformSseLine(\"event: message\")).toBe(\"event: message\");\n      expect(callTransformSseLine(\": heartbeat\")).toBe(\": heartbeat\");\n    });\n\n    it(\"handles data: [DONE] unchanged\", () => {\n      expect(callTransformSseLine(\"data: [DONE]\")).toBe(\"data: [DONE]\");\n    });\n\n    it(\"handles invalid JSON gracefully\", () => {\n      expect(callTransformSseLine(\"data: not-json\")).toBe(\"data: not-json\");\n      expect(callTransformSseLine(\"data: {invalid}\")).toBe(\"data: {invalid}\");\n    });\n\n    it(\"passes through valid JSON without thinking parts\", () => {\n      const payload = { candidates: [{ content: { parts: [{ text: \"hello\" }] } }] };\n      const line = `data: ${JSON.stringify(payload)}`;\n      const result = callTransformSseLine(line);\n      expect(result).toContain(\"data:\");\n      expect(result).toContain(\"hello\");\n    });\n\n    it(\"transforms thinking parts in streaming data\", () => {\n      const payload = {\n        candidates: [{\n          content: {\n            parts: [{ thought: true, text: \"reasoning...\" }]\n          }\n        }]\n      };\n      const line = `data: ${JSON.stringify(payload)}`;\n      const result = callTransformSseLine(line);\n      expect(result).toContain(\"data:\");\n    });\n  });\n\n  describe(\"transformStreamingPayload\", () => {\n    it(\"handles empty string\", () => {\n      expect(transformStreamingPayload(\"\")).toBe(\"\");\n    });\n\n    it(\"handles single line without data prefix\", () => {\n      expect(transformStreamingPayload(\"event: ping\")).toBe(\"event: ping\");\n    });\n\n    it(\"handles multiple lines\", () => {\n      const input = \"event: message\\ndata: [DONE]\\n\";\n      const result = transformStreamingPayload(input);\n      expect(result).toContain(\"event: message\");\n      expect(result).toContain(\"data: [DONE]\");\n    });\n\n    it(\"preserves line structure\", () => {\n      const input = \"line1\\nline2\\nline3\";\n      const result = transformStreamingPayload(input);\n      const lines = result.split(\"\\n\");\n      expect(lines.length).toBe(3);\n    });\n  });\n\n  describe(\"createStreamingTransformer\", () => {\n    it(\"returns a TransformStream\", () => {\n      const store = createMockSignatureStore();\n      const transformer = createStreamingTransformer(store, defaultCallbacks);\n      expect(transformer).toBeInstanceOf(TransformStream);\n      expect(transformer.readable).toBeDefined();\n      expect(transformer.writable).toBeDefined();\n    });\n\n    it(\"accepts optional signatureSessionKey\", () => {\n      const store = createMockSignatureStore();\n      const transformer = createStreamingTransformer(store, defaultCallbacks, { signatureSessionKey: \"session-key\" });\n      expect(transformer).toBeInstanceOf(TransformStream);\n    });\n\n    it(\"accepts optional debugText\", () => {\n      const store = createMockSignatureStore();\n      const transformer = createStreamingTransformer(store, defaultCallbacks, { signatureSessionKey: \"session-key\", debugText: \"debug info\" });\n      expect(transformer).toBeInstanceOf(TransformStream);\n    });\n\n    it(\"accepts cacheSignatures flag\", () => {\n      const store = createMockSignatureStore();\n      const transformer = createStreamingTransformer(store, defaultCallbacks, { signatureSessionKey: \"session-key\", cacheSignatures: true });\n      expect(transformer).toBeInstanceOf(TransformStream);\n    });\n\n    it(\"processes chunks through the stream\", async () => {\n      const store = createMockSignatureStore();\n      const transformer = createStreamingTransformer(store, defaultCallbacks);\n      const encoder = new TextEncoder();\n      const decoder = new TextDecoder();\n      \n      const input = encoder.encode(\"data: [DONE]\\n\");\n      const outputChunks: Uint8Array[] = [];\n      \n      const writer = transformer.writable.getWriter();\n      const reader = transformer.readable.getReader();\n      \n      const readPromise = (async () => {\n        while (true) {\n          const { done, value } = await reader.read();\n          if (done) break;\n          if (value) outputChunks.push(value);\n        }\n      })();\n      \n      await writer.write(input);\n      await writer.close();\n      await readPromise;\n      \n      const output = outputChunks.map(chunk => decoder.decode(chunk)).join(\"\");\n      expect(output).toContain(\"[DONE]\");\n    });\n  });\n\n  describe(\"prepareAntigravityRequest\", () => {\n    const mockAccessToken = \"test-token\";\n    const mockProjectId = \"test-project\";\n\n    it(\"returns unchanged request for non-generative-language URLs\", () => {\n      const result = prepareAntigravityRequest(\n        \"https://example.com/api\",\n        { method: \"POST\" },\n        mockAccessToken,\n        mockProjectId\n      );\n      expect(result.streaming).toBe(false);\n      expect(result.request).toBe(\"https://example.com/api\");\n    });\n\n    it(\"returns unchanged request for URLs without model pattern\", () => {\n      const result = prepareAntigravityRequest(\n        \"https://generativelanguage.googleapis.com/v1/models\",\n        { method: \"POST\" },\n        mockAccessToken,\n        mockProjectId\n      );\n      expect(result.streaming).toBe(false);\n    });\n\n    it(\"detects streaming from generateStreamContent action\", () => {\n      const result = prepareAntigravityRequest(\n        \"https://generativelanguage.googleapis.com/v1beta/models/gemini-pro:streamGenerateContent\",\n        { method: \"POST\", body: JSON.stringify({ contents: [] }) },\n        mockAccessToken,\n        mockProjectId\n      );\n      expect(result.streaming).toBe(true);\n    });\n\n    it(\"detects non-streaming from generateContent action\", () => {\n      const result = prepareAntigravityRequest(\n        \"https://generativelanguage.googleapis.com/v1beta/models/gemini-pro:generateContent\",\n        { method: \"POST\", body: JSON.stringify({ contents: [] }) },\n        mockAccessToken,\n        mockProjectId\n      );\n      expect(result.streaming).toBe(false);\n    });\n\n    it(\"sets Authorization header with Bearer token\", () => {\n      const result = prepareAntigravityRequest(\n        \"https://generativelanguage.googleapis.com/v1beta/models/gemini-pro:generateContent\",\n        { method: \"POST\", body: JSON.stringify({ contents: [] }) },\n        mockAccessToken,\n        mockProjectId\n      );\n      const headers = result.init.headers as Headers;\n      expect(headers.get(\"Authorization\")).toBe(\"Bearer test-token\");\n    });\n\nit(\"removes x-api-key header\", () => {\n      const result = prepareAntigravityRequest(\n        \"https://generativelanguage.googleapis.com/v1beta/models/gemini-pro:generateContent\",\n        { method: \"POST\", body: JSON.stringify({ contents: [] }), headers: { \"x-api-key\": \"old-key\" } },\n        mockAccessToken,\n        mockProjectId\n      );\n      const headers = result.init.headers as Headers;\n      expect(headers.get(\"x-api-key\")).toBeNull();\n    });\n\n    it(\"removes x-goog-user-project header for antigravity headerStyle\", () => {\n      const result = prepareAntigravityRequest(\n        \"https://generativelanguage.googleapis.com/v1beta/models/claude-opus-4-6-thinking:generateContent\",\n        { method: \"POST\", body: JSON.stringify({ contents: [] }), headers: { \"x-goog-user-project\": \"my-project\" } },\n        mockAccessToken,\n        mockProjectId,\n        undefined,\n        \"antigravity\"\n      );\n      const headers = result.init.headers as Headers;\n      expect(headers.get(\"x-goog-user-project\")).toBeNull();\n    });\n\n    it(\"removes x-goog-user-project header for gemini-cli headerStyle\", () => {\n      const result = prepareAntigravityRequest(\n        \"https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent\",\n        { method: \"POST\", body: JSON.stringify({ contents: [] }), headers: { \"x-goog-user-project\": \"my-project\" } },\n        mockAccessToken,\n        mockProjectId,\n        undefined,\n        \"gemini-cli\"\n      );\n      const headers = result.init.headers as Headers;\n      expect(headers.get(\"x-goog-user-project\")).toBeNull();\n    });\n\n    it(\"uses exact Code Assist headers for gemini-cli headerStyle\", () => {\n      const result = prepareAntigravityRequest(\n        \"https://generativelanguage.googleapis.com/v1beta/models/gemini-3-flash:generateContent\",\n        { method: \"POST\", body: JSON.stringify({ contents: [] }) },\n        mockAccessToken,\n        mockProjectId,\n        undefined,\n        \"gemini-cli\"\n      );\n      const headers = result.init.headers as Headers;\n      expect(headers.get(\"User-Agent\")).toBe(\"google-api-nodejs-client/9.15.1\");\n      expect(headers.get(\"X-Goog-Api-Client\")).toBe(\"gl-node/22.17.0\");\n      expect(headers.get(\"Client-Metadata\")).toBe(\"ideType=IDE_UNSPECIFIED,platform=PLATFORM_UNSPECIFIED,pluginType=GEMINI\");\n    });\n\n    it(\"builds gemini-cli wrapped body without antigravity-only fields\", () => {\n      const result = prepareAntigravityRequest(\n        \"https://generativelanguage.googleapis.com/v1beta/models/gemini-3-flash:generateContent\",\n        { method: \"POST\", body: JSON.stringify({ contents: [{ role: \"user\", parts: [{ text: \"hi\" }] }] }) },\n        mockAccessToken,\n        \"\",\n        undefined,\n        \"gemini-cli\"\n      );\n      const parsed = JSON.parse(result.init.body as string);\n      expect(parsed).toHaveProperty(\"project\", \"\");\n      expect(parsed).toHaveProperty(\"model\");\n      expect(parsed).toHaveProperty(\"request\");\n      expect(parsed.requestType).toBeUndefined();\n      expect(parsed.userAgent).toBeUndefined();\n      expect(parsed.requestId).toBeUndefined();\n    });\n\n    it(\"identifies Claude models correctly\", () => {\n      const result = prepareAntigravityRequest(\n        \"https://generativelanguage.googleapis.com/v1beta/models/claude-sonnet-4-20250514:generateContent\",\n        { method: \"POST\", body: JSON.stringify({ contents: [] }) },\n        mockAccessToken,\n        mockProjectId\n      );\n      expect(result.effectiveModel).toContain(\"claude\");\n    });\n\n    it(\"identifies Gemini models correctly\", () => {\n      const result = prepareAntigravityRequest(\n        \"https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-pro:generateContent\",\n        { method: \"POST\", body: JSON.stringify({ contents: [] }) },\n        mockAccessToken,\n        mockProjectId\n      );\n      expect(result.effectiveModel).toContain(\"gemini\");\n    });\n\n    it(\"uses custom endpoint override\", () => {\n      const customEndpoint = \"https://custom.api.com\";\n      const result = prepareAntigravityRequest(\n        \"https://generativelanguage.googleapis.com/v1beta/models/gemini-pro:generateContent\",\n        { method: \"POST\", body: JSON.stringify({ contents: [] }) },\n        mockAccessToken,\n        mockProjectId,\n        customEndpoint\n      );\n      expect(result.endpoint).toContain(customEndpoint);\n    });\n\n    it(\"handles wrapped Antigravity body format\", () => {\n      const wrappedBody = {\n        project: \"my-project\",\n        request: { contents: [{ parts: [{ text: \"Hello\" }] }] }\n      };\n      const result = prepareAntigravityRequest(\n        \"https://generativelanguage.googleapis.com/v1beta/models/gemini-pro:generateContent\",\n        { method: \"POST\", body: JSON.stringify(wrappedBody) },\n        mockAccessToken,\n        mockProjectId\n      );\n      expect(result.streaming).toBe(false);\n    });\n\n    it(\"handles unwrapped body format\", () => {\n      const unwrappedBody = {\n        contents: [{ parts: [{ text: \"Hello\" }] }]\n      };\n      const result = prepareAntigravityRequest(\n        \"https://generativelanguage.googleapis.com/v1beta/models/gemini-pro:generateContent\",\n        { method: \"POST\", body: JSON.stringify(unwrappedBody) },\n        mockAccessToken,\n        mockProjectId\n      );\n      expect(result.streaming).toBe(false);\n    });\n\n    it(\"does not add Claude auto-caching to wrapped request by default\", () => {\n      const wrappedBody = {\n        project: \"my-project\",\n        request: { messages: [{ role: \"user\", content: [{ type: \"text\", text: \"Hello\" }] }] }\n      };\n      const result = prepareAntigravityRequest(\n        \"https://generativelanguage.googleapis.com/v1beta/models/claude-3-7-sonnet:generateContent\",\n        { method: \"POST\", body: JSON.stringify(wrappedBody) },\n        mockAccessToken,\n        mockProjectId,\n      );\n\n      const wrapped = JSON.parse(result.init.body as string);\n      expect(wrapped.request.cache_control).toBeUndefined();\n    });\n\n    it(\"does not add Claude auto-caching to unwrapped request by default\", () => {\n      const unwrappedBody = {\n        messages: [{ role: \"user\", content: [{ type: \"text\", text: \"Hello\" }] }]\n      };\n      const result = prepareAntigravityRequest(\n        \"https://generativelanguage.googleapis.com/v1beta/models/claude-3-7-sonnet:generateContent\",\n        { method: \"POST\", body: JSON.stringify(unwrappedBody) },\n        mockAccessToken,\n        mockProjectId,\n      );\n\n      const wrapped = JSON.parse(result.init.body as string);\n      expect(wrapped.request.cache_control).toBeUndefined();\n    });\n\n    it(\"adds Claude auto-caching when enabled\", () => {\n      const unwrappedBody = {\n        messages: [{ role: \"user\", content: [{ type: \"text\", text: \"Hello\" }] }]\n      };\n      const result = prepareAntigravityRequest(\n        \"https://generativelanguage.googleapis.com/v1beta/models/claude-3-7-sonnet:generateContent\",\n        { method: \"POST\", body: JSON.stringify(unwrappedBody) },\n        mockAccessToken,\n        mockProjectId,\n        undefined,\n        \"antigravity\",\n        false,\n        { claudePromptAutoCaching: true },\n      );\n\n      const wrapped = JSON.parse(result.init.body as string);\n      expect(wrapped.request.cache_control).toEqual({ type: \"ephemeral\" });\n    });\n\n    it(\"strips Claude thinking blocks when keep_thinking is false (unwrapped)\", () => {\n      const result = withKeepThinking(false, () => prepareAntigravityRequest(\n        \"https://generativelanguage.googleapis.com/v1beta/models/claude-opus-4-6-thinking:generateContent\",\n        {\n          method: \"POST\",\n          body: JSON.stringify({\n            contents: [\n              {\n                role: \"model\",\n                parts: [\n                  {\n                    thought: true,\n                    text: \"foreign-thought-unwrapped\",\n                    thoughtSignature: \"f\".repeat(MIN_SIGNATURE_LENGTH + 8),\n                  },\n                  { functionCall: { name: \"weather\", args: {} } },\n                ],\n              },\n            ],\n          }),\n        },\n        mockAccessToken,\n        mockProjectId,\n      ));\n\n      const wrapped = JSON.parse(result.init.body as string);\n      const parts = wrapped.request.contents[0].parts as Array<Record<string, unknown>>;\n      const thinkingParts = parts.filter((part) =>\n        part.thought === true\n        || part.type === \"thinking\"\n        || part.type === \"redacted_thinking\"\n        || part.type === \"reasoning\",\n      );\n\n      expect(thinkingParts).toHaveLength(0);\n      expect(result.needsSignedThinkingWarmup).toBe(false);\n    });\n\n    it(\"strips Claude thinking blocks when keep_thinking is false (wrapped)\", () => {\n      const result = withKeepThinking(false, () => prepareAntigravityRequest(\n        \"https://generativelanguage.googleapis.com/v1beta/models/claude-opus-4-6-thinking:generateContent\",\n        {\n          method: \"POST\",\n          body: JSON.stringify({\n            project: \"my-project\",\n            request: {\n              contents: [\n                {\n                  role: \"model\",\n                  parts: [\n                    {\n                      thought: true,\n                      text: \"foreign-thought-wrapped\",\n                      thoughtSignature: \"w\".repeat(MIN_SIGNATURE_LENGTH + 8),\n                    },\n                    { functionCall: { name: \"weather\", args: {} } },\n                  ],\n                },\n              ],\n            },\n          }),\n        },\n        mockAccessToken,\n        mockProjectId,\n      ));\n\n      const wrapped = JSON.parse(result.init.body as string);\n      const parts = wrapped.request.contents[0].parts as Array<Record<string, unknown>>;\n      const thinkingParts = parts.filter((part) =>\n        part.thought === true\n        || part.type === \"thinking\"\n        || part.type === \"redacted_thinking\"\n        || part.type === \"reasoning\",\n      );\n\n      expect(thinkingParts).toHaveLength(0);\n      expect(result.needsSignedThinkingWarmup).toBe(false);\n    });\n\n    it(\"does not trust foreign Gemini thoughtSignature when keep_thinking is true\", () => {\n      const foreignSignature = \"x\".repeat(MIN_SIGNATURE_LENGTH + 8);\n      const result = withKeepThinking(true, () => prepareAntigravityRequest(\n        \"https://generativelanguage.googleapis.com/v1beta/models/claude-opus-4-6-thinking:generateContent\",\n        {\n          method: \"POST\",\n          body: JSON.stringify({\n            contents: [\n              {\n                role: \"model\",\n                parts: [\n                  {\n                    thought: true,\n                    text: \"foreign-thought-keep-true\",\n                    thoughtSignature: foreignSignature,\n                  },\n                  { functionCall: { name: \"weather\", args: {} } },\n                ],\n              },\n            ],\n          }),\n        },\n        mockAccessToken,\n        mockProjectId,\n      ));\n\n      const wrapped = JSON.parse(result.init.body as string);\n      const parts = wrapped.request.contents[0].parts as Array<Record<string, unknown>>;\n      const thinkingBlock = parts.find((part) =>\n        part.thought === true || part.type === \"thinking\" || part.type === \"redacted_thinking\",\n      );\n      const signature = typeof thinkingBlock?.signature === \"string\"\n        ? thinkingBlock.signature\n        : thinkingBlock?.thoughtSignature;\n\n      expect(JSON.stringify(wrapped)).not.toContain(foreignSignature);\n      if (thinkingBlock) {\n        expect(signature).toBe(SKIP_THOUGHT_SIGNATURE);\n      }\n    });\n\n    it(\"replaces foreign Claude signatures with sentinel when keep_thinking is true\", () => {\n      const foreignSignature = \"y\".repeat(MIN_SIGNATURE_LENGTH + 8);\n      const result = withKeepThinking(true, () => prepareAntigravityRequest(\n        \"https://generativelanguage.googleapis.com/v1beta/models/claude-opus-4-6-thinking:generateContent\",\n        {\n          method: \"POST\",\n          body: JSON.stringify({\n            messages: [\n              {\n                role: \"assistant\",\n                content: [\n                  {\n                    type: \"thinking\",\n                    thinking: \"foreign-message-thinking\",\n                    signature: foreignSignature,\n                  },\n                  {\n                    type: \"tool_use\",\n                    id: \"tool-1\",\n                    name: \"weather\",\n                    input: {},\n                  },\n                ],\n              },\n            ],\n          }),\n        },\n        mockAccessToken,\n        mockProjectId,\n      ));\n\n      const wrapped = JSON.parse(result.init.body as string);\n      const content = wrapped.request.messages[0].content as Array<Record<string, unknown>>;\n      const thinkingBlock = content.find((block) => block.type === \"thinking\" || block.type === \"redacted_thinking\");\n\n      expect(thinkingBlock).toBeTruthy();\n      expect(thinkingBlock?.signature).toBe(SKIP_THOUGHT_SIGNATURE);\n      expect(JSON.stringify(content)).not.toContain(foreignSignature);\n      expect(result.needsSignedThinkingWarmup).toBe(false);\n    });\n\n    it(\"returns requestedModel matching URL model\", () => {\n      const result = prepareAntigravityRequest(\n        \"https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent\",\n        { method: \"POST\", body: JSON.stringify({ contents: [] }) },\n        mockAccessToken,\n        mockProjectId\n      );\n      expect(result.requestedModel).toBe(\"gemini-2.5-flash\");\n    });\n\n    it(\"handles empty body gracefully\", () => {\n      const result = prepareAntigravityRequest(\n        \"https://generativelanguage.googleapis.com/v1beta/models/gemini-pro:generateContent\",\n        { method: \"POST\", body: JSON.stringify({}) },\n        mockAccessToken,\n        mockProjectId\n      );\n      expect(result.streaming).toBe(false);\n    });\n\n    it(\"handles minimal valid JSON body\", () => {\n      const result = prepareAntigravityRequest(\n        \"https://generativelanguage.googleapis.com/v1beta/models/gemini-pro:generateContent\",\n        { method: \"POST\", body: JSON.stringify({ contents: [] }) },\n        mockAccessToken,\n        mockProjectId\n      );\n      expect(result.streaming).toBe(false);\n    });\n\n    it(\"removes contents entries with empty or invalid parts\", () => {\n      const result = prepareAntigravityRequest(\n        \"https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent\",\n        {\n          method: \"POST\",\n          body: JSON.stringify({\n            contents: [\n              { role: \"user\", parts: [] },\n              { role: \"model\", parts: [null, { text: \"kept\" }] },\n              { role: \"user\", parts: null },\n            ],\n            systemInstruction: {\n              role: \"user\",\n              parts: [null, { text: \"system kept\" }],\n            },\n          }),\n        },\n        mockAccessToken,\n        mockProjectId,\n        undefined,\n        \"gemini-cli\",\n      );\n\n      const wrapped = JSON.parse(result.init.body as string);\n      expect(wrapped.request.contents).toHaveLength(1);\n      expect(wrapped.request.contents[0]).toEqual({\n        role: \"model\",\n        parts: [{ text: \"kept\" }],\n      });\n      expect(wrapped.request.systemInstruction.parts).toEqual([{ text: \"system kept\" }]);\n    });\n\n    it(\"drops systemInstruction when all parts are invalid\", () => {\n      const result = prepareAntigravityRequest(\n        \"https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent\",\n        {\n          method: \"POST\",\n          body: JSON.stringify({\n            contents: [{ role: \"user\", parts: [{ text: \"hi\" }] }],\n            systemInstruction: {\n              role: \"user\",\n              parts: [null],\n            },\n          }),\n        },\n        mockAccessToken,\n        mockProjectId,\n        undefined,\n        \"gemini-cli\",\n      );\n\n      const wrapped = JSON.parse(result.init.body as string);\n      expect(wrapped.request.systemInstruction).toBeUndefined();\n    });\n\n    it(\"preserves headerStyle in response\", () => {\n      const result = prepareAntigravityRequest(\n        \"https://generativelanguage.googleapis.com/v1beta/models/gemini-pro:generateContent\",\n        { method: \"POST\", body: JSON.stringify({ contents: [] }) },\n        mockAccessToken,\n        mockProjectId,\n        undefined,\n        \"gemini-cli\"\n      );\n      expect(result.headerStyle).toBe(\"gemini-cli\");\n    });\n\n    describe(\"Issue #103: model name transformation during quota fallback\", () => {\n      it(\"transforms gemini-3-flash-preview to gemini-3-flash for antigravity headerStyle\", () => {\n        const result = prepareAntigravityRequest(\n          \"https://generativelanguage.googleapis.com/v1beta/models/gemini-3-flash-preview:generateContent\",\n          { method: \"POST\", body: JSON.stringify({ contents: [] }) },\n          mockAccessToken,\n          mockProjectId,\n          undefined,\n          \"antigravity\"\n        );\n        expect(result.effectiveModel).toBe(\"gemini-3-flash\");\n      });\n\n      it(\"transforms gemini-3-pro-preview to gemini-3-pro-low for antigravity headerStyle\", () => {\n        const result = prepareAntigravityRequest(\n          \"https://generativelanguage.googleapis.com/v1beta/models/gemini-3-pro-preview:generateContent\",\n          { method: \"POST\", body: JSON.stringify({ contents: [] }) },\n          mockAccessToken,\n          mockProjectId,\n          undefined,\n          \"antigravity\"\n        );\n        expect(result.effectiveModel).toBe(\"gemini-3-pro-low\");\n      });\n\n      it(\"transforms gemini-3.1-pro-preview to gemini-3.1-pro-low for antigravity headerStyle\", () => {\n        const result = prepareAntigravityRequest(\n          \"https://generativelanguage.googleapis.com/v1beta/models/gemini-3.1-pro-preview:generateContent\",\n          { method: \"POST\", body: JSON.stringify({ contents: [] }) },\n          mockAccessToken,\n          mockProjectId,\n          undefined,\n          \"antigravity\"\n        );\n        expect(result.effectiveModel).toBe(\"gemini-3.1-pro-low\");\n      });\n\n      it(\"transforms gemini-3.1-pro-preview-customtools to gemini-3.1-pro-low for antigravity headerStyle\", () => {\n        const result = prepareAntigravityRequest(\n          \"https://generativelanguage.googleapis.com/v1beta/models/gemini-3.1-pro-preview-customtools:generateContent\",\n          { method: \"POST\", body: JSON.stringify({ contents: [] }) },\n          mockAccessToken,\n          mockProjectId,\n          undefined,\n          \"antigravity\"\n        );\n        expect(result.effectiveModel).toBe(\"gemini-3.1-pro-low\");\n      });\n\n      it(\"transforms gemini-3-flash to gemini-3-flash-preview for gemini-cli headerStyle\", () => {\n        const result = prepareAntigravityRequest(\n          \"https://generativelanguage.googleapis.com/v1beta/models/gemini-3-flash:generateContent\",\n          { method: \"POST\", body: JSON.stringify({ contents: [] }) },\n          mockAccessToken,\n          mockProjectId,\n          undefined,\n          \"gemini-cli\"\n        );\n        expect(result.effectiveModel).toBe(\"gemini-3-flash-preview\");\n      });\n\n      it(\"transforms gemini-3-pro-low to gemini-3-pro-preview for gemini-cli headerStyle\", () => {\n        const result = prepareAntigravityRequest(\n          \"https://generativelanguage.googleapis.com/v1beta/models/gemini-3-pro-low:generateContent\",\n          { method: \"POST\", body: JSON.stringify({ contents: [] }) },\n          mockAccessToken,\n          mockProjectId,\n          undefined,\n          \"gemini-cli\"\n        );\n        expect(result.effectiveModel).toBe(\"gemini-3-pro-preview\");\n      });\n\n      it(\"transforms gemini-3.1-pro-low to gemini-3.1-pro-preview for gemini-cli headerStyle\", () => {\n        const result = prepareAntigravityRequest(\n          \"https://generativelanguage.googleapis.com/v1beta/models/gemini-3.1-pro-low:generateContent\",\n          { method: \"POST\", body: JSON.stringify({ contents: [] }) },\n          mockAccessToken,\n          mockProjectId,\n          undefined,\n          \"gemini-cli\"\n        );\n        expect(result.effectiveModel).toBe(\"gemini-3.1-pro-preview\");\n      });\n\n      it(\"keeps gemini-3.1-pro-preview-customtools unchanged for gemini-cli headerStyle\", () => {\n        const result = prepareAntigravityRequest(\n          \"https://generativelanguage.googleapis.com/v1beta/models/gemini-3.1-pro-preview-customtools:generateContent\",\n          { method: \"POST\", body: JSON.stringify({ contents: [] }) },\n          mockAccessToken,\n          mockProjectId,\n          undefined,\n          \"gemini-cli\"\n        );\n        expect(result.effectiveModel).toBe(\"gemini-3.1-pro-preview-customtools\");\n      });\n\n      it(\"keeps non-Gemini-3 models unchanged regardless of headerStyle\", () => {\n        const result = prepareAntigravityRequest(\n          \"https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent\",\n          { method: \"POST\", body: JSON.stringify({ contents: [] }) },\n          mockAccessToken,\n          mockProjectId,\n          undefined,\n          \"antigravity\"\n        );\n        expect(result.effectiveModel).toBe(\"gemini-2.5-flash\");\n      });\n    });\n  });\n\n  describe(\"transformAntigravityResponse\", () => {\n    it(\"injects [ThinkingResolution] details when debug_tui is enabled\", async () => {\n      initializeDebug({\n        ...DEFAULT_CONFIG,\n        debug: false,\n        debug_tui: true,\n      });\n\n      const response = new Response(\n        JSON.stringify({\n          error: {\n            code: 500,\n            message: \"Upstream error\",\n            status: \"INTERNAL\",\n          },\n        }),\n        {\n          status: 500,\n          headers: { \"content-type\": \"application/json\" },\n        },\n      );\n\n      const transformed = await transformAntigravityResponse(\n        response,\n        false,\n        undefined,\n        \"gemini-2.5-pro\",\n        \"test-project\",\n        \"https://daily-cloudcode-pa.sandbox.googleapis.com/v1internal:generateContent\",\n        \"gemini-2.5-pro\",\n        \"session-1\",\n        0,\n        \"summary\",\n        undefined,\n        [\n          \"status=500 INTERNAL\",\n          \"endpoint=https://daily-cloudcode-pa.sandbox.googleapis.com/v1internal:generateContent\",\n          \"account=test@example.com\",\n        ],\n      );\n\n      const bodyText = await transformed.text();\n      expect(bodyText).toContain(\"[ThinkingResolution]\");\n      expect(bodyText).toContain(\"status=500 INTERNAL\");\n      expect(bodyText).toContain(\"endpoint=https://daily-cloudcode-pa.sandbox.googleapis.com/v1internal:generateContent\");\n      expect(bodyText).toContain(\"account=test@example.com\");\n\n      initializeDebug(DEFAULT_CONFIG);\n    });\n\n    it(\"does not misclassify generic INVALID_ARGUMENT as thinking recovery from debug metadata\", async () => {\n      const response = new Response(\n        JSON.stringify({\n          error: {\n            code: 400,\n            message: \"Request contains an invalid argument.\",\n            status: \"INVALID_ARGUMENT\",\n          },\n        }),\n        {\n          status: 400,\n          headers: { \"content-type\": \"application/json\" },\n        },\n      );\n\n      const transformed = await transformAntigravityResponse(\n        response,\n        true,\n        undefined,\n        \"antigravity-claude-opus-4-6-thinking\",\n        \"test-project\",\n        \"https://daily-cloudcode-pa.sandbox.googleapis.com/v1internal:streamGenerateContent?alt=sse\",\n        \"claude-opus-4-6-thinking\",\n        \"session-1\",\n        0,\n        \"expected=1 found=0\",\n      );\n\n      await expect(transformed.text()).resolves.toContain(\"Request contains an invalid argument.\");\n    });\n\n    it(\"rethrows THINKING_RECOVERY_NEEDED for outer retry handling\", async () => {\n      const response = new Response(\n        JSON.stringify({\n          error: {\n            code: 400,\n            message: \"Thinking must start with a thinking block before tool use.\",\n            status: \"INVALID_ARGUMENT\",\n          },\n        }),\n        {\n          status: 400,\n          headers: { \"content-type\": \"application/json\" },\n        },\n      );\n\n      await expect(\n        transformAntigravityResponse(\n          response,\n          true,\n          undefined,\n          \"antigravity-claude-opus-4-6-thinking\",\n          \"test-project\",\n          \"https://daily-cloudcode-pa.sandbox.googleapis.com/v1internal:streamGenerateContent?alt=sse\",\n          \"claude-opus-4-6-thinking\",\n          \"session-1\",\n        ),\n      ).rejects.toMatchObject({ message: \"THINKING_RECOVERY_NEEDED\" });\n    });\n  });\n});\n"
  },
  {
    "path": "src/plugin/request.ts",
    "content": "import crypto from \"node:crypto\";\nimport {\n  ANTIGRAVITY_ENDPOINT,\n  GEMINI_CLI_ENDPOINT,\n  GEMINI_CLI_HEADERS,\n  EMPTY_SCHEMA_PLACEHOLDER_NAME,\n  EMPTY_SCHEMA_PLACEHOLDER_DESCRIPTION,\n  SKIP_THOUGHT_SIGNATURE,\n  getRandomizedHeaders,\n  type HeaderStyle,\n} from \"../constants\";\nimport { cacheSignature, getCachedSignature } from \"./cache\";\nimport { getKeepThinking } from \"./config\";\nimport {\n  createStreamingTransformer,\n  transformSseLine,\n  transformStreamingPayload,\n} from \"./core/streaming\";\nimport { defaultSignatureStore } from \"./stores/signature-store\";\nimport {\n  DEBUG_MESSAGE_PREFIX,\n  isDebugEnabled,\n  isDebugTuiEnabled,\n  logAntigravityDebugResponse,\n  logCacheStats,\n  type AntigravityDebugContext,\n} from \"./debug\";\nimport { createLogger } from \"./logger\";\nimport {\n  cleanJSONSchemaForAntigravity,\n  DEFAULT_THINKING_BUDGET,\n  deepFilterThinkingBlocks,\n  extractThinkingConfig,\n  extractVariantThinkingConfig,\n  extractUsageFromSsePayload,\n  extractUsageMetadata,\n  fixToolResponseGrouping,\n  validateAndFixClaudeToolPairing,\n  applyToolPairingFixes,\n  injectParameterSignatures,\n  injectToolHardeningInstruction,\n  isThinkingCapableModel,\n  normalizeThinkingConfig,\n  parseAntigravityApiBody,\n  resolveThinkingConfig,\n  rewriteAntigravityPreviewAccessError,\n  transformThinkingParts,\n  type AntigravityApiBody,\n} from \"./request-helpers\";\nimport {\n  CLAUDE_TOOL_SYSTEM_INSTRUCTION,\n  CLAUDE_DESCRIPTION_PROMPT,\n  ANTIGRAVITY_SYSTEM_INSTRUCTION,\n} from \"../constants\";\nimport {\n  analyzeConversationState,\n  closeToolLoopForThinking,\n  needsThinkingRecovery,\n} from \"./thinking-recovery\";\nimport { sanitizeCrossModelPayloadInPlace } from \"./transform/cross-model-sanitizer\";\nimport { isGemini3Model, isImageGenerationModel, buildImageGenerationConfig, applyGeminiTransforms } from \"./transform\";\nimport {\n  resolveModelWithTier,\n  resolveModelWithVariant,\n  resolveModelForHeaderStyle,\n  isClaudeModel,\n  isClaudeThinkingModel,\n  CLAUDE_THINKING_MAX_OUTPUT_TOKENS,\n  type ThinkingTier,\n} from \"./transform\";\nimport { detectErrorType } from \"./recovery\";\nimport { getSessionFingerprint, buildFingerprintHeaders, type Fingerprint } from \"./fingerprint\";\nimport type { GoogleSearchConfig } from \"./transform/types\";\n\nconst log = createLogger(\"request\");\n\nconst PLUGIN_SESSION_ID = `-${crypto.randomUUID()}`;\n\nconst sessionDisplayedThinkingHashes = new Set<string>();\n\nconst MIN_SIGNATURE_LENGTH = 50;\n\nfunction buildSignatureSessionKey(\n  sessionId: string,\n  model?: string,\n  conversationKey?: string,\n  projectKey?: string,\n): string {\n  const modelKey = typeof model === \"string\" && model.trim() ? model.toLowerCase() : \"unknown\";\n  const projectPart = typeof projectKey === \"string\" && projectKey.trim()\n    ? projectKey.trim()\n    : \"default\";\n  const conversationPart = typeof conversationKey === \"string\" && conversationKey.trim()\n    ? conversationKey.trim()\n    : \"default\";\n  return `${sessionId}:${modelKey}:${projectPart}:${conversationPart}`;\n}\n\n\n\n\n\n\n\nfunction shouldCacheThinkingSignatures(model?: string): boolean {\n  if (typeof model !== \"string\") return false;\n  const lower = model.toLowerCase();\n  // Both Claude and Gemini 3 models require thought signature caching\n  // for multi-turn conversations with function calling\n  return lower.includes(\"claude\") || lower.includes(\"gemini-3\");\n}\n\nfunction hashConversationSeed(seed: string): string {\n  return crypto.createHash(\"sha256\").update(seed, \"utf8\").digest(\"hex\").slice(0, 16);\n}\n\nfunction extractTextFromContent(content: unknown): string {\n  if (typeof content === \"string\") {\n    return content;\n  }\n  if (!Array.isArray(content)) {\n    return \"\";\n  }\n  for (const block of content) {\n    if (!block || typeof block !== \"object\") {\n      continue;\n    }\n    const anyBlock = block as any;\n    if (typeof anyBlock.text === \"string\") {\n      return anyBlock.text;\n    }\n    if (anyBlock.text && typeof anyBlock.text === \"object\" && typeof anyBlock.text.text === \"string\") {\n      return anyBlock.text.text;\n    }\n  }\n  return \"\";\n}\n\nfunction extractConversationSeedFromMessages(messages: any[]): string {\n  const system = messages.find((message) => message?.role === \"system\");\n  const users = messages.filter((message) => message?.role === \"user\");\n  const firstUser = users[0];\n  const lastUser = users.length > 0 ? users[users.length - 1] : undefined;\n  const systemText = system ? extractTextFromContent(system.content) : \"\";\n  const userText = firstUser ? extractTextFromContent(firstUser.content) : \"\";\n  const fallbackUserText = !userText && lastUser ? extractTextFromContent(lastUser.content) : \"\";\n  return [systemText, userText || fallbackUserText].filter(Boolean).join(\"|\");\n}\n\nfunction extractConversationSeedFromContents(contents: any[]): string {\n  const users = contents.filter((content) => content?.role === \"user\");\n  const firstUser = users[0];\n  const lastUser = users.length > 0 ? users[users.length - 1] : undefined;\n  const primaryUser = firstUser && Array.isArray(firstUser.parts) ? extractTextFromContent(firstUser.parts) : \"\";\n  if (primaryUser) {\n    return primaryUser;\n  }\n  if (lastUser && Array.isArray(lastUser.parts)) {\n    return extractTextFromContent(lastUser.parts);\n  }\n  return \"\";\n}\n\nfunction resolveConversationKey(requestPayload: Record<string, unknown>): string | undefined {\n  const anyPayload = requestPayload as any;\n  const candidates = [\n    anyPayload.conversationId,\n    anyPayload.conversation_id,\n    anyPayload.thread_id,\n    anyPayload.threadId,\n    anyPayload.chat_id,\n    anyPayload.chatId,\n    anyPayload.sessionId,\n    anyPayload.session_id,\n    anyPayload.metadata?.conversation_id,\n    anyPayload.metadata?.conversationId,\n    anyPayload.metadata?.thread_id,\n    anyPayload.metadata?.threadId,\n  ];\n\n  for (const candidate of candidates) {\n    if (typeof candidate === \"string\" && candidate.trim()) {\n      return candidate.trim();\n    }\n  }\n\n  const systemSeed = extractTextFromContent(\n    (anyPayload.systemInstruction as any)?.parts\n    ?? anyPayload.systemInstruction\n    ?? anyPayload.system\n    ?? anyPayload.system_instruction,\n  );\n  const messageSeed = Array.isArray(anyPayload.messages)\n    ? extractConversationSeedFromMessages(anyPayload.messages)\n    : Array.isArray(anyPayload.contents)\n      ? extractConversationSeedFromContents(anyPayload.contents)\n      : \"\";\n  const seed = [systemSeed, messageSeed].filter(Boolean).join(\"|\");\n  if (!seed) {\n    return undefined;\n  }\n  return `seed-${hashConversationSeed(seed)}`;\n}\n\nfunction resolveConversationKeyFromRequests(requestObjects: Array<Record<string, unknown>>): string | undefined {\n  for (const req of requestObjects) {\n    const key = resolveConversationKey(req);\n    if (key) {\n      return key;\n    }\n  }\n  return undefined;\n}\n\nfunction resolveProjectKey(candidate?: unknown, fallback?: string): string | undefined {\n  if (typeof candidate === \"string\" && candidate.trim()) {\n    return candidate.trim();\n  }\n  if (typeof fallback === \"string\" && fallback.trim()) {\n    return fallback.trim();\n  }\n  return undefined;\n}\n\nfunction formatDebugLinesForThinking(lines: string[]): string {\n  const cleaned = lines\n    .map((line) => line.trim())\n    .filter((line) => line.length > 0)\n    .slice(-50);\n  const prelude = `[ThinkingResolution] source=debug_tui lines=${cleaned.length}`;\n  return `${DEBUG_MESSAGE_PREFIX}\\n- ${prelude}\\n${cleaned.map((line) => `- ${line}`).join(\"\\n\")}`;\n}\n\nfunction injectDebugThinking(response: unknown, debugText: string): unknown {\n  if (!response || typeof response !== \"object\") {\n    return response;\n  }\n\n  const resp = response as any;\n\n  if (Array.isArray(resp.candidates) && resp.candidates.length > 0) {\n    const candidates = resp.candidates.slice();\n    const first = candidates[0];\n\n    if (\n      first &&\n      typeof first === \"object\" &&\n      first.content &&\n      typeof first.content === \"object\" &&\n      Array.isArray(first.content.parts)\n    ) {\n      const parts = [{ thought: true, text: debugText }, ...first.content.parts];\n      candidates[0] = { ...first, content: { ...first.content, parts } };\n      return { ...resp, candidates };\n    }\n\n    return resp;\n  }\n\n  if (Array.isArray(resp.content)) {\n    const content = [{ type: \"thinking\", thinking: debugText }, ...resp.content];\n    return { ...resp, content };\n  }\n\n  if (!resp.reasoning_content) {\n    return { ...resp, reasoning_content: debugText };\n  }\n\n  return resp;\n}\n\n/**\n * Synthetic thinking placeholder text used when keep_thinking=true but debug mode is off.\n * Injected via the same path as debug text (injectDebugThinking) to ensure consistent\n * signature caching and multi-turn handling.\n */\nconst SYNTHETIC_THINKING_PLACEHOLDER = \"[Thinking preserved]\\n\";\n\nfunction stripInjectedDebugFromParts(parts: unknown): unknown {\n  if (!Array.isArray(parts)) {\n    return parts;\n  }\n\n  return parts.filter((part) => {\n    if (!part || typeof part !== \"object\") {\n      return true;\n    }\n\n    const record = part as any;\n    const text =\n      typeof record.text === \"string\"\n        ? record.text\n        : typeof record.thinking === \"string\"\n          ? record.thinking\n          : undefined;\n\n    // Strip debug blocks and synthetic thinking placeholders\n    if (text && (text.startsWith(DEBUG_MESSAGE_PREFIX) || text.startsWith(SYNTHETIC_THINKING_PLACEHOLDER.trim()))) {\n      return false;\n    }\n\n    return true;\n  });\n}\n\nfunction stripInjectedDebugFromRequestPayload(payload: Record<string, unknown>): void {\n  const anyPayload = payload as any;\n\n  if (Array.isArray(anyPayload.contents)) {\n    anyPayload.contents = anyPayload.contents.map((content: any) => {\n      if (!content || typeof content !== \"object\") {\n        return content;\n      }\n\n      if (Array.isArray(content.parts)) {\n        return { ...content, parts: stripInjectedDebugFromParts(content.parts) };\n      }\n\n      if (Array.isArray(content.content)) {\n        return { ...content, content: stripInjectedDebugFromParts(content.content) };\n      }\n\n      return content;\n    });\n  }\n\n  if (Array.isArray(anyPayload.messages)) {\n    anyPayload.messages = anyPayload.messages.map((message: any) => {\n      if (!message || typeof message !== \"object\") {\n        return message;\n      }\n\n      if (Array.isArray(message.content)) {\n        return { ...message, content: stripInjectedDebugFromParts(message.content) };\n      }\n\n      return message;\n    });\n  }\n}\n\nfunction isValidRequestPart(part: unknown): boolean {\n  if (!part || typeof part !== \"object\") {\n    return false;\n  }\n\n  const record = part as Record<string, unknown>;\n\n  return (\n    Object.prototype.hasOwnProperty.call(record, \"text\") ||\n    Object.prototype.hasOwnProperty.call(record, \"functionCall\") ||\n    Object.prototype.hasOwnProperty.call(record, \"functionResponse\") ||\n    Object.prototype.hasOwnProperty.call(record, \"inlineData\") ||\n    Object.prototype.hasOwnProperty.call(record, \"fileData\") ||\n    Object.prototype.hasOwnProperty.call(record, \"executableCode\") ||\n    Object.prototype.hasOwnProperty.call(record, \"codeExecutionResult\") ||\n    Object.prototype.hasOwnProperty.call(record, \"thought\")\n  );\n}\n\nfunction sanitizeRequestPayloadForAntigravity(payload: Record<string, unknown>): void {\n  const anyPayload = payload as any;\n\n  if (Array.isArray(anyPayload.contents)) {\n    anyPayload.contents = anyPayload.contents\n      .map((content: unknown) => {\n        if (!content || typeof content !== \"object\") {\n          return null;\n        }\n\n        const contentRecord = content as Record<string, unknown>;\n        const rawParts = Array.isArray(contentRecord.parts) ? contentRecord.parts : [];\n        let foundFirstFunctionCall = false;\n\n        const sanitizedParts = rawParts.filter(isValidRequestPart).map((part: any) => {\n          if (part && typeof part === \"object\" && part.functionCall) {\n            let sig = part.thoughtSignature || part.thought_signature;\n\n            // Only the first functionCall part in a block should have the signature.\n            // If it's the first one and missing a valid signature, inject the sentinel\n            // to prevent the API from rejecting the request with a 400 error.\n            if (!foundFirstFunctionCall) {\n              foundFirstFunctionCall = true;\n              if (!sig || sig.length < MIN_SIGNATURE_LENGTH) {\n                sig = SKIP_THOUGHT_SIGNATURE;\n              }\n            } else {\n              // Parallel function calls MUST NOT have a signature\n              sig = undefined;\n            }\n\n            if (sig) {\n              return { ...part, thought_signature: sig, thoughtSignature: sig };\n            }\n            \n            // If not the first part, just return the part without adding any signature keys\n            const newPart = { ...part };\n            delete newPart.thoughtSignature;\n            delete newPart.thought_signature;\n            return newPart;\n          }\n          return part;\n        });\n\n        if (sanitizedParts.length === 0) {\n          return null;\n        }\n\n        return {\n          ...contentRecord,\n          parts: sanitizedParts,\n        };\n      })\n      .filter((content: unknown): content is Record<string, unknown> => content !== null);\n  }\n\n  const systemInstruction = anyPayload.systemInstruction;\n  if (systemInstruction && typeof systemInstruction === \"object\" && !Array.isArray(systemInstruction)) {\n    const sys = systemInstruction as Record<string, unknown>;\n    if (Array.isArray(sys.parts)) {\n      const sanitizedSystemParts = sys.parts.filter(isValidRequestPart);\n      if (sanitizedSystemParts.length > 0) {\n        sys.parts = sanitizedSystemParts;\n      } else {\n        delete anyPayload.systemInstruction;\n      }\n    }\n  }\n}\n\nfunction isGeminiToolUsePart(part: any): boolean {\n  return !!(part && typeof part === \"object\" && (part.functionCall || part.tool_use || part.toolUse));\n}\n\nfunction isGeminiThinkingPart(part: any): boolean {\n  return !!(\n    part &&\n    typeof part === \"object\" &&\n    (part.thought === true || part.type === \"thinking\" || part.type === \"reasoning\")\n  );\n}\n\n// Sentinel value used when signature recovery fails - allows Claude to handle gracefully\n// by redacting the thinking block instead of rejecting the request entirely.\n// Reference: LLM-API-Key-Proxy uses this pattern for Gemini 3 tool calls.\nconst SENTINEL_SIGNATURE = \"skip_thought_signature_validator\";\n\nfunction getThinkingPartText(part: any): string {\n  if (!part || typeof part !== \"object\") {\n    return \"\";\n  }\n\n  if (typeof part.text === \"string\") {\n    return part.text;\n  }\n\n  if (typeof part.thinking === \"string\") {\n    return part.thinking;\n  }\n\n  return \"\";\n}\n\nfunction hasCachedMatchingSignature(part: any, sessionId: string): boolean {\n  if (!part || typeof part !== \"object\") {\n    return false;\n  }\n\n  const text = getThinkingPartText(part);\n  if (!text) {\n    return false;\n  }\n\n  const expectedSignature = getCachedSignature(sessionId, text);\n  if (!expectedSignature) {\n    return false;\n  }\n\n  if (part.thought === true) {\n    return part.thoughtSignature === expectedSignature;\n  }\n\n  return part.signature === expectedSignature;\n}\n\nfunction ensureThoughtSignature(part: any, sessionId: string): any {\n  if (!part || typeof part !== \"object\") {\n    return part;\n  }\n\n  if (!sessionId) {\n    return part;\n  }\n\n  const text = getThinkingPartText(part);\n  if (!text) {\n    return part;\n  }\n\n  if (part.thought === true) {\n    return { ...part, thoughtSignature: SENTINEL_SIGNATURE };\n  }\n\n  if (part.type === \"thinking\" || part.type === \"reasoning\" || part.type === \"redacted_thinking\") {\n    return { ...part, signature: SENTINEL_SIGNATURE };\n  }\n\n  return part;\n}\n\nfunction hasSignedThinkingPart(part: any, sessionId?: string): boolean {\n  if (!part || typeof part !== \"object\") {\n    return false;\n  }\n\n  if (part.thought === true) {\n    if (part.thoughtSignature === SENTINEL_SIGNATURE || part.thoughtSignature === SKIP_THOUGHT_SIGNATURE) {\n      return true;\n    }\n\n    if (typeof part.thoughtSignature !== \"string\" || part.thoughtSignature.length < MIN_SIGNATURE_LENGTH) {\n      return false;\n    }\n\n    if (!sessionId) {\n      return true;\n    }\n\n    return hasCachedMatchingSignature(part, sessionId);\n  }\n\n  if (part.type === \"thinking\" || part.type === \"reasoning\" || part.type === \"redacted_thinking\") {\n    if (part.signature === SENTINEL_SIGNATURE || part.signature === SKIP_THOUGHT_SIGNATURE) {\n      return true;\n    }\n\n    if (typeof part.signature !== \"string\" || part.signature.length < MIN_SIGNATURE_LENGTH) {\n      return false;\n    }\n\n    if (!sessionId) {\n      return true;\n    }\n\n    return hasCachedMatchingSignature(part, sessionId);\n  }\n\n  return false;\n}\n\nfunction ensureThinkingBeforeToolUseInContents(contents: any[], signatureSessionKey: string): any[] {\n  return contents.map((content: any) => {\n    if (!content || typeof content !== \"object\" || !Array.isArray(content.parts)) {\n      return content;\n    }\n\n    const role = content.role;\n    if (role !== \"model\" && role !== \"assistant\") {\n      return content;\n    }\n\n    const parts = content.parts as any[];\n    const hasToolUse = parts.some(isGeminiToolUsePart);\n    if (!hasToolUse) {\n      return content;\n    }\n\n    const thinkingParts = parts.filter(isGeminiThinkingPart).map((p) => ensureThoughtSignature(p, signatureSessionKey));\n    const otherParts = parts.filter((p) => !isGeminiThinkingPart(p));\n    const hasSignedThinking = thinkingParts.some((part) => hasSignedThinkingPart(part, signatureSessionKey));\n\n    if (hasSignedThinking) {\n      return { ...content, parts: [...thinkingParts, ...otherParts] };\n    }\n\n    const lastThinking = defaultSignatureStore.get(signatureSessionKey);\n    if (!lastThinking) {\n      // No cached signature available - strip thinking blocks entirely\n      // Claude requires valid signatures, and we can't fake them\n      // Return only tool_use parts without any thinking to avoid signature validation errors\n      log.debug(\"Stripping thinking from tool_use content (no valid cached signature)\", { signatureSessionKey });\n      return { ...content, parts: otherParts };\n    }\n\n    const injected = {\n      thought: true,\n      text: lastThinking.text,\n      thoughtSignature: SENTINEL_SIGNATURE,\n    };\n\n    return { ...content, parts: [injected, ...otherParts] };\n  });\n}\n\nfunction ensureMessageThinkingSignature(block: any, sessionId: string): any {\n  if (!block || typeof block !== \"object\") {\n    return block;\n  }\n\n  if (block.type !== \"thinking\" && block.type !== \"redacted_thinking\") {\n    return block;\n  }\n\n  const text = getThinkingPartText(block);\n  if (!text) {\n    return block;\n  }\n\n  if (!sessionId) {\n    return block;\n  }\n\n  return { ...block, signature: SKIP_THOUGHT_SIGNATURE };\n}\n\nfunction hasToolUseInContents(contents: any[]): boolean {\n  return contents.some((content: any) => {\n    if (!content || typeof content !== \"object\" || !Array.isArray(content.parts)) {\n      return false;\n    }\n    return (content.parts as any[]).some(isGeminiToolUsePart);\n  });\n}\n\nfunction hasSignedThinkingInContents(contents: any[], sessionId?: string): boolean {\n  return contents.some((content: any) => {\n    if (!content || typeof content !== \"object\" || !Array.isArray(content.parts)) {\n      return false;\n    }\n    return (content.parts as any[]).some((part) => hasSignedThinkingPart(part, sessionId));\n  });\n}\n\nfunction hasToolUseInMessages(messages: any[]): boolean {\n  return messages.some((message: any) => {\n    if (!message || typeof message !== \"object\" || !Array.isArray(message.content)) {\n      return false;\n    }\n    return (message.content as any[]).some(\n      (block) => block && typeof block === \"object\" && (block.type === \"tool_use\" || block.type === \"tool_result\"),\n    );\n  });\n}\n\nfunction hasSignedThinkingInMessages(messages: any[], sessionId?: string): boolean {\n  return messages.some((message: any) => {\n    if (!message || typeof message !== \"object\" || !Array.isArray(message.content)) {\n      return false;\n    }\n    return (message.content as any[]).some((block) => hasSignedThinkingPart(block, sessionId));\n  });\n}\n\nfunction ensureThinkingBeforeToolUseInMessages(messages: any[], signatureSessionKey: string): any[] {\n  return messages.map((message: any) => {\n    if (!message || typeof message !== \"object\" || !Array.isArray(message.content)) {\n      return message;\n    }\n\n    if (message.role !== \"assistant\") {\n      return message;\n    }\n\n    const blocks = message.content as any[];\n    const hasToolUse = blocks.some((b) => b && typeof b === \"object\" && (b.type === \"tool_use\" || b.type === \"tool_result\"));\n    if (!hasToolUse) {\n      return message;\n    }\n\n    const thinkingBlocks = blocks\n      .filter((b) => b && typeof b === \"object\" && (b.type === \"thinking\" || b.type === \"redacted_thinking\"))\n      .map((b) => ensureMessageThinkingSignature(b, signatureSessionKey));\n\n    const otherBlocks = blocks.filter((b) => !(b && typeof b === \"object\" && (b.type === \"thinking\" || b.type === \"redacted_thinking\")));\n    const hasSignedThinking = thinkingBlocks.some((block) => hasSignedThinkingPart(block, signatureSessionKey));\n\n    if (hasSignedThinking) {\n      return { ...message, content: [...thinkingBlocks, ...otherBlocks] };\n    }\n\n    const lastThinking = defaultSignatureStore.get(signatureSessionKey);\n    if (!lastThinking) {\n      // No cached signature available - use sentinel to bypass validation\n      // This handles cache miss scenarios (restart, session mismatch, expiry)\n      const existingThinking = thinkingBlocks[0];\n      const thinkingText = existingThinking?.thinking || existingThinking?.text || \"\";\n      log.debug(\"Injecting sentinel signature (cache miss)\", { signatureSessionKey });\n      const sentinelBlock = {\n        type: \"thinking\",\n        thinking: thinkingText,\n        signature: SKIP_THOUGHT_SIGNATURE,\n      };\n      return { ...message, content: [sentinelBlock, ...otherBlocks] };\n    }\n\n    const injected = {\n      type: \"thinking\",\n      thinking: lastThinking.text,\n      signature: SKIP_THOUGHT_SIGNATURE,\n    };\n\n    return { ...message, content: [injected, ...otherBlocks] };\n  });\n}\n\n/**\n * Gets the stable session ID for this plugin instance.\n */\nexport function getPluginSessionId(): string {\n  return PLUGIN_SESSION_ID;\n}\n\nfunction generateSyntheticProjectId(): string {\n  const adjectives = [\"useful\", \"bright\", \"swift\", \"calm\", \"bold\"];\n  const nouns = [\"fuze\", \"wave\", \"spark\", \"flow\", \"core\"];\n  const adj = adjectives[Math.floor(Math.random() * adjectives.length)];\n  const noun = nouns[Math.floor(Math.random() * nouns.length)];\n  const randomPart = crypto.randomUUID().slice(0, 5).toLowerCase();\n  return `${adj}-${noun}-${randomPart}`;\n}\n\nconst STREAM_ACTION = \"streamGenerateContent\";\n\n/**\n * Detects requests headed to the Google Generative Language API so we can intercept them.\n */\nexport function isGenerativeLanguageRequest(input: RequestInfo): input is string {\n  return typeof input === \"string\" && input.includes(\"generativelanguage.googleapis.com\");\n}\n\n/**\n * Options for request preparation.\n */\nexport interface PrepareRequestOptions {\n  /** Enable Claude tool hardening (parameter signatures + system instruction). Default: true */\n  claudeToolHardening?: boolean;\n  /** Enable top-level Claude prompt auto-caching (`cache_control`). Default: false */\n  claudePromptAutoCaching?: boolean;\n  /** Google Search configuration (global default) */\n  googleSearch?: GoogleSearchConfig;\n  /** Per-account fingerprint for rate limit mitigation. Falls back to session fingerprint if not provided. */\n  fingerprint?: Fingerprint;\n}\n\nexport function prepareAntigravityRequest(\n  input: RequestInfo,\n  init: RequestInit | undefined,\n  accessToken: string,\n  projectId: string,\n  endpointOverride?: string,\n  headerStyle: HeaderStyle = \"antigravity\",\n  forceThinkingRecovery = false,\n  options?: PrepareRequestOptions,\n): {\n  request: RequestInfo;\n  init: RequestInit;\n  streaming: boolean;\n  requestedModel?: string;\n  effectiveModel?: string;\n  projectId?: string;\n  endpoint?: string;\n  sessionId?: string;\n  toolDebugMissing?: number;\n  toolDebugSummary?: string;\n  toolDebugPayload?: string;\n  needsSignedThinkingWarmup?: boolean;\n  headerStyle: HeaderStyle;\n  thinkingRecoveryMessage?: string;\n} {\n  const baseInit: RequestInit = { ...init };\n  const headers = new Headers(init?.headers ?? {});\n  let resolvedProjectId = projectId?.trim() || \"\";\n  let toolDebugMissing = 0;\n  const toolDebugSummaries: string[] = [];\n  let toolDebugPayload: string | undefined;\n  let sessionId: string | undefined;\n  let needsSignedThinkingWarmup = false;\n  let thinkingRecoveryMessage: string | undefined;\n\n  if (!isGenerativeLanguageRequest(input)) {\n    return {\n      request: input,\n      init: { ...baseInit, headers },\n      streaming: false,\n      headerStyle,\n    };\n  }\n\n  headers.set(\"Authorization\", `Bearer ${accessToken}`);\n  headers.delete(\"x-api-key\");\n  // Strip x-goog-user-project header to prevent 403 auth/license conflicts.\n  // This header is added by OpenCode/AI SDK and can force project-level checks\n  // that are not required for Antigravity/Gemini CLI OAuth requests.\n  headers.delete(\"x-goog-user-project\");\n\n  const match = input.match(/\\/models\\/([^:]+):(\\w+)/);\n  if (!match) {\n    return {\n      request: input,\n      init: { ...baseInit, headers },\n      streaming: false,\n      headerStyle,\n    };\n  }\n\n  const [, rawModel = \"\", rawAction = \"\"] = match;\n  const requestedModel = rawModel;\n\n  const resolved = resolveModelForHeaderStyle(rawModel, headerStyle);\n  let effectiveModel = resolved.actualModel;\n\n  const streaming = rawAction === STREAM_ACTION;\n  const defaultEndpoint = headerStyle === \"gemini-cli\" ? GEMINI_CLI_ENDPOINT : ANTIGRAVITY_ENDPOINT;\n  const baseEndpoint = endpointOverride ?? defaultEndpoint;\n  const transformedUrl = `${baseEndpoint}/v1internal:${rawAction}${streaming ? \"?alt=sse\" : \"\"}`;\n\n  const isClaude = isClaudeModel(resolved.actualModel);\n  const isClaudeThinking = isClaudeThinkingModel(resolved.actualModel);\n  const keepThinkingEnabled = getKeepThinking();\n  const enableClaudePromptAutoCaching = options?.claudePromptAutoCaching ?? false;\n\n  // Tier-based thinking configuration from model resolver (can be overridden by variant config)\n  let tierThinkingBudget = resolved.thinkingBudget;\n  let tierThinkingLevel = resolved.thinkingLevel;\n  let signatureSessionKey = buildSignatureSessionKey(\n    PLUGIN_SESSION_ID,\n    effectiveModel,\n    undefined,\n    resolveProjectKey(projectId),\n  );\n\n  let body = baseInit.body;\n  if (typeof baseInit.body === \"string\" && baseInit.body) {\n    try {\n      const parsedBody = JSON.parse(baseInit.body) as Record<string, unknown>;\n      const isWrapped = typeof parsedBody.project === \"string\" && \"request\" in parsedBody;\n\n      if (isWrapped) {\n        const wrappedBody = {\n          ...parsedBody,\n          model: effectiveModel,\n        } as Record<string, unknown>;\n\n        // Some callers may already send an Antigravity-wrapped body.\n        // We still need to sanitize Claude thinking blocks (remove cache_control)\n        // and attach a stable sessionId so multi-turn signature caching works.\n        const requestRoot = wrappedBody.request;\n        const requestObjects: Array<Record<string, unknown>> = [];\n\n        if (requestRoot && typeof requestRoot === \"object\") {\n          requestObjects.push(requestRoot as Record<string, unknown>);\n          const nested = (requestRoot as any).request;\n          if (nested && typeof nested === \"object\") {\n            requestObjects.push(nested as Record<string, unknown>);\n          }\n        }\n\n        const conversationKey = resolveConversationKeyFromRequests(requestObjects);\n        // Strip tier suffix from model for cache key to prevent cache misses on tier change\n        // e.g., \"claude-opus-4-6-thinking-high\" -> \"claude-opus-4-6-thinking\"\n        const modelForCacheKey = effectiveModel.replace(/-(minimal|low|medium|high)$/i, \"\");\n        signatureSessionKey = buildSignatureSessionKey(PLUGIN_SESSION_ID, modelForCacheKey, conversationKey, resolveProjectKey(parsedBody.project));\n\n        if (requestObjects.length > 0) {\n          sessionId = signatureSessionKey;\n        }\n\n        for (const req of requestObjects) {\n          // Use stable session ID for signature caching across multi-turn conversations\n          (req as any).sessionId = signatureSessionKey;\n          stripInjectedDebugFromRequestPayload(req as Record<string, unknown>);\n\n          if (isClaude) {\n            // Step 0: Sanitize cross-model metadata (strips Gemini signatures when sending to Claude)\n            sanitizeCrossModelPayloadInPlace(req, { targetModel: effectiveModel });\n\n            // Step 1: Strip corrupted/unsigned thinking blocks FIRST\n            deepFilterThinkingBlocks(req, signatureSessionKey, getCachedSignature, true);\n\n            if (enableClaudePromptAutoCaching && (req as any).cache_control === undefined) {\n              (req as any).cache_control = { type: \"ephemeral\" };\n            }\n\n            // Step 2: THEN inject signed thinking from cache (after stripping)\n            if (isClaudeThinking && keepThinkingEnabled && Array.isArray((req as any).contents)) {\n              (req as any).contents = ensureThinkingBeforeToolUseInContents((req as any).contents, signatureSessionKey);\n            }\n            if (isClaudeThinking && keepThinkingEnabled && Array.isArray((req as any).messages)) {\n              (req as any).messages = ensureThinkingBeforeToolUseInMessages((req as any).messages, signatureSessionKey);\n            }\n\n            // Step 3: Apply tool pairing fixes (ID assignment, response matching, orphan recovery)\n            applyToolPairingFixes(req as Record<string, unknown>, true);\n          }\n        }\n\n        if (isClaudeThinking && keepThinkingEnabled && sessionId) {\n          const hasToolUse = requestObjects.some((req) =>\n            (Array.isArray((req as any).contents) && hasToolUseInContents((req as any).contents)) ||\n            (Array.isArray((req as any).messages) && hasToolUseInMessages((req as any).messages)),\n          );\n          const hasSignedThinking = requestObjects.some((req) =>\n            (Array.isArray((req as any).contents) && hasSignedThinkingInContents((req as any).contents, signatureSessionKey)) ||\n            (Array.isArray((req as any).messages) && hasSignedThinkingInMessages((req as any).messages, signatureSessionKey)),\n          );\n          const hasCachedThinking = defaultSignatureStore.has(signatureSessionKey);\n          needsSignedThinkingWarmup = hasToolUse && !hasSignedThinking && !hasCachedThinking;\n        }\n\n        body = JSON.stringify(wrappedBody);\n      } else {\n        const requestPayload: Record<string, unknown> = { ...parsedBody };\n\n        const rawGenerationConfig = requestPayload.generationConfig as Record<string, unknown> | undefined;\n        const extraBody = requestPayload.extra_body as Record<string, unknown> | undefined;\n\n        const variantConfig = extractVariantThinkingConfig(\n          requestPayload.providerOptions as Record<string, unknown> | undefined,\n          rawGenerationConfig\n        );\n        const isGemini3 = effectiveModel.toLowerCase().includes(\"gemini-3\");\n\n        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)}`);\n\n        if (variantConfig?.thinkingLevel && isGemini3) {\n          // Gemini 3 native format - use thinkingLevel directly\n          tierThinkingLevel = variantConfig.thinkingLevel;\n          tierThinkingBudget = undefined;\n        } else if (variantConfig?.thinkingBudget) {\n          if (isGemini3) {\n            // Legacy format for Gemini 3 - convert with deprecation warning\n            log.warn(\"[Deprecated] Using thinkingBudget for Gemini 3 model. Use thinkingLevel instead.\");\n            tierThinkingLevel = variantConfig.thinkingBudget <= 8192 ? \"low\"\n              : variantConfig.thinkingBudget <= 16384 ? \"medium\" : \"high\";\n            tierThinkingBudget = undefined;\n          } else {\n            // Claude / Gemini 2.5 - use budget directly\n            tierThinkingBudget = variantConfig.thinkingBudget;\n            tierThinkingLevel = undefined;\n          }\n        }\n\n        if (isClaude) {\n          if (!requestPayload.toolConfig) {\n            requestPayload.toolConfig = {};\n          }\n          if (typeof requestPayload.toolConfig === \"object\" && requestPayload.toolConfig !== null) {\n            const toolConfig = requestPayload.toolConfig as Record<string, unknown>;\n            if (!toolConfig.functionCallingConfig) {\n              toolConfig.functionCallingConfig = {};\n            }\n            if (typeof toolConfig.functionCallingConfig === \"object\" && toolConfig.functionCallingConfig !== null) {\n              (toolConfig.functionCallingConfig as Record<string, unknown>).mode = \"VALIDATED\";\n            }\n          }\n        }\n\n        // Resolve thinking configuration based on user settings and model capabilities\n        // Image generation models don't support thinking - skip thinking config entirely\n        const isImageModel = isImageGenerationModel(effectiveModel);\n        const userThinkingConfig = isImageModel ? undefined : extractThinkingConfig(requestPayload, rawGenerationConfig, extraBody);\n        const hasAssistantHistory = Array.isArray(requestPayload.contents) &&\n          requestPayload.contents.some((c: any) => c?.role === \"model\" || c?.role === \"assistant\");\n\n        // Claude Sonnet 4.6 is non-thinking only.\n        // Ignore any client-provided thinkingConfig for this model.\n        const lowerEffective = effectiveModel.toLowerCase();\n        const isClaudeSonnetNonThinking = lowerEffective === \"claude-sonnet-4-6\";\n        const effectiveUserThinkingConfig = (isClaudeSonnetNonThinking || isImageModel) ? undefined : userThinkingConfig;\n\n        // For image models, add imageConfig instead of thinkingConfig\n        if (isImageModel) {\n          const imageConfig = buildImageGenerationConfig();\n          const generationConfig = (rawGenerationConfig ?? {}) as Record<string, unknown>;\n          generationConfig.imageConfig = imageConfig;\n          // Remove any thinkingConfig that might have been set\n          delete generationConfig.thinkingConfig;\n          // Set reasonable defaults for image generation\n          if (!generationConfig.candidateCount) {\n            generationConfig.candidateCount = 1;\n          }\n          requestPayload.generationConfig = generationConfig;\n\n          // Add safety settings for image generation (permissive to allow creative content)\n          if (!requestPayload.safetySettings) {\n            requestPayload.safetySettings = [\n              { category: \"HARM_CATEGORY_HARASSMENT\", threshold: \"BLOCK_ONLY_HIGH\" },\n              { category: \"HARM_CATEGORY_HATE_SPEECH\", threshold: \"BLOCK_ONLY_HIGH\" },\n              { category: \"HARM_CATEGORY_SEXUALLY_EXPLICIT\", threshold: \"BLOCK_ONLY_HIGH\" },\n              { category: \"HARM_CATEGORY_DANGEROUS_CONTENT\", threshold: \"BLOCK_ONLY_HIGH\" },\n              { category: \"HARM_CATEGORY_CIVIC_INTEGRITY\", threshold: \"BLOCK_ONLY_HIGH\" },\n            ];\n          }\n\n          // Image models don't support tools - remove them entirely\n          delete requestPayload.tools;\n          delete requestPayload.toolConfig;\n\n          // Replace system instruction with a simple image generation prompt\n          // Image models should not receive agentic coding assistant instructions\n          requestPayload.systemInstruction = {\n            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.\" }]\n          };\n        } else {\n          const finalThinkingConfig = resolveThinkingConfig(\n            effectiveUserThinkingConfig,\n            isClaudeSonnetNonThinking ? false : (resolved.isThinkingModel ?? isThinkingCapableModel(effectiveModel)),\n            isClaude,\n            hasAssistantHistory,\n          );\n\n          const normalizedThinking = normalizeThinkingConfig(finalThinkingConfig);\n          if (normalizedThinking) {\n            // Use tier-based thinking budget if specified via model suffix, otherwise fall back to user config\n            const thinkingBudget = tierThinkingBudget ?? normalizedThinking.thinkingBudget;\n\n            // Build thinking config based on model type\n            let thinkingConfig: Record<string, unknown>;\n\n            if (isClaudeThinking) {\n              // Claude uses snake_case keys\n              thinkingConfig = {\n                include_thoughts: normalizedThinking.includeThoughts ?? true,\n                ...(typeof thinkingBudget === \"number\" && thinkingBudget > 0\n                  ? { thinking_budget: thinkingBudget }\n                  : {}),\n              };\n            } else if (tierThinkingLevel) {\n              // Gemini 3 uses thinkingLevel string (low/medium/high)\n              thinkingConfig = {\n                includeThoughts: normalizedThinking.includeThoughts,\n                thinkingLevel: tierThinkingLevel,\n              };\n            } else {\n              // Gemini 2.5 and others use numeric budget\n              thinkingConfig = {\n                includeThoughts: normalizedThinking.includeThoughts,\n                ...(typeof thinkingBudget === \"number\" && thinkingBudget > 0 ? { thinkingBudget } : {}),\n              };\n            }\n\n            if (rawGenerationConfig) {\n              rawGenerationConfig.thinkingConfig = thinkingConfig;\n\n              if (isClaudeThinking && typeof thinkingBudget === \"number\" && thinkingBudget > 0) {\n                const currentMax = (rawGenerationConfig.maxOutputTokens ?? rawGenerationConfig.max_output_tokens) as number | undefined;\n                if (!currentMax || currentMax <= thinkingBudget) {\n                  rawGenerationConfig.maxOutputTokens = CLAUDE_THINKING_MAX_OUTPUT_TOKENS;\n                  if (rawGenerationConfig.max_output_tokens !== undefined) {\n                    delete rawGenerationConfig.max_output_tokens;\n                  }\n                }\n              }\n\n              requestPayload.generationConfig = rawGenerationConfig;\n            } else {\n              const generationConfig: Record<string, unknown> = { thinkingConfig };\n\n              if (isClaudeThinking && typeof thinkingBudget === \"number\" && thinkingBudget > 0) {\n                generationConfig.maxOutputTokens = CLAUDE_THINKING_MAX_OUTPUT_TOKENS;\n              }\n\n              requestPayload.generationConfig = generationConfig;\n            }\n          } else if (rawGenerationConfig?.thinkingConfig) {\n            delete rawGenerationConfig.thinkingConfig;\n            requestPayload.generationConfig = rawGenerationConfig;\n          }\n        } // End of else block for non-image models\n\n        // Clean up thinking fields from extra_body\n        if (extraBody) {\n          delete extraBody.thinkingConfig;\n          delete extraBody.thinking;\n        }\n        delete requestPayload.thinkingConfig;\n        delete requestPayload.thinking;\n\n        if (\"system_instruction\" in requestPayload) {\n          requestPayload.systemInstruction = requestPayload.system_instruction;\n          delete requestPayload.system_instruction;\n        }\n\n        if (isClaudeThinking && Array.isArray(requestPayload.tools) && requestPayload.tools.length > 0) {\n          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.\";\n          const existing = requestPayload.systemInstruction;\n\n          if (typeof existing === \"string\") {\n            requestPayload.systemInstruction = existing.trim().length > 0 ? `${existing}\\n\\n${hint}` : hint;\n          } else if (existing && typeof existing === \"object\") {\n            const sys = existing as Record<string, unknown>;\n            const partsValue = sys.parts;\n\n            if (Array.isArray(partsValue)) {\n              const parts = partsValue as unknown[];\n              let appended = false;\n\n              for (let i = parts.length - 1; i >= 0; i--) {\n                const part = parts[i];\n                if (part && typeof part === \"object\") {\n                  const partRecord = part as Record<string, unknown>;\n                  const text = partRecord.text;\n                  if (typeof text === \"string\") {\n                    partRecord.text = `${text}\\n\\n${hint}`;\n                    appended = true;\n                    break;\n                  }\n                }\n              }\n\n              if (!appended) {\n                parts.push({ text: hint });\n              }\n            } else {\n              sys.parts = [{ text: hint }];\n            }\n\n            requestPayload.systemInstruction = sys;\n          } else if (Array.isArray(requestPayload.contents)) {\n            requestPayload.systemInstruction = { parts: [{ text: hint }] };\n          }\n        }\n\n        const cachedContentFromExtra =\n          typeof requestPayload.extra_body === \"object\" && requestPayload.extra_body\n            ? (requestPayload.extra_body as Record<string, unknown>).cached_content ??\n            (requestPayload.extra_body as Record<string, unknown>).cachedContent\n            : undefined;\n        const cachedContent =\n          (requestPayload.cached_content as string | undefined) ??\n          (requestPayload.cachedContent as string | undefined) ??\n          (cachedContentFromExtra as string | undefined);\n        if (cachedContent) {\n          requestPayload.cachedContent = cachedContent;\n        }\n\n        delete requestPayload.cached_content;\n        delete requestPayload.cachedContent;\n        if (requestPayload.extra_body && typeof requestPayload.extra_body === \"object\") {\n          delete (requestPayload.extra_body as Record<string, unknown>).cached_content;\n          delete (requestPayload.extra_body as Record<string, unknown>).cachedContent;\n          if (Object.keys(requestPayload.extra_body as Record<string, unknown>).length === 0) {\n            delete requestPayload.extra_body;\n          }\n        }\n\n        // Normalize tools. For Claude models, keep full function declarations (names + schemas).\n        const hasTools = Array.isArray(requestPayload.tools) && requestPayload.tools.length > 0;\n\n        if (hasTools) {\n          if (isClaude) {\n            const functionDeclarations: any[] = [];\n            const passthroughTools: any[] = [];\n\n            const normalizeSchema = (schema: any) => {\n              const createPlaceholderSchema = (base: any = {}) => ({\n                ...base,\n                type: \"object\",\n                properties: {\n                  [EMPTY_SCHEMA_PLACEHOLDER_NAME]: {\n                    type: \"boolean\",\n                    description: EMPTY_SCHEMA_PLACEHOLDER_DESCRIPTION,\n                  },\n                },\n                required: [EMPTY_SCHEMA_PLACEHOLDER_NAME],\n              });\n\n              if (!schema || typeof schema !== \"object\" || Array.isArray(schema)) {\n                toolDebugMissing += 1;\n                return createPlaceholderSchema();\n              }\n\n              const cleaned = cleanJSONSchemaForAntigravity(schema);\n\n              if (!cleaned || typeof cleaned !== \"object\" || Array.isArray(cleaned)) {\n                toolDebugMissing += 1;\n                return createPlaceholderSchema();\n              }\n\n              // Claude VALIDATED mode requires tool parameters to be an object schema\n              // with at least one property.\n              const hasProperties =\n                cleaned.properties &&\n                typeof cleaned.properties === \"object\" &&\n                Object.keys(cleaned.properties).length > 0;\n\n              cleaned.type = \"object\";\n\n              if (!hasProperties) {\n                cleaned.properties = {\n                  [EMPTY_SCHEMA_PLACEHOLDER_NAME]: {\n                    type: \"boolean\",\n                    description: EMPTY_SCHEMA_PLACEHOLDER_DESCRIPTION,\n                  },\n                };\n                cleaned.required = Array.isArray(cleaned.required)\n                  ? Array.from(new Set([...cleaned.required, EMPTY_SCHEMA_PLACEHOLDER_NAME]))\n                  : [EMPTY_SCHEMA_PLACEHOLDER_NAME];\n              }\n\n              return cleaned;\n            };\n\n            (requestPayload.tools as any[]).forEach((tool: any) => {\n              const pushDeclaration = (decl: any, source: string) => {\n                const schema =\n                  decl?.parameters ||\n                  decl?.parametersJsonSchema ||\n                  decl?.input_schema ||\n                  decl?.inputSchema ||\n                  tool.parameters ||\n                  tool.parametersJsonSchema ||\n                  tool.input_schema ||\n                  tool.inputSchema ||\n                  tool.function?.parameters ||\n                  tool.function?.parametersJsonSchema ||\n                  tool.function?.input_schema ||\n                  tool.function?.inputSchema ||\n                  tool.custom?.parameters ||\n                  tool.custom?.parametersJsonSchema ||\n                  tool.custom?.input_schema;\n\n                let name =\n                  decl?.name ||\n                  tool.name ||\n                  tool.function?.name ||\n                  tool.custom?.name ||\n                  `tool-${functionDeclarations.length}`;\n\n                // Sanitize tool name: must be alphanumeric with underscores, no special chars\n                name = String(name).replace(/[^a-zA-Z0-9_-]/g, \"_\").slice(0, 64);\n\n                const description =\n                  decl?.description ||\n                  tool.description ||\n                  tool.function?.description ||\n                  tool.custom?.description ||\n                  \"\";\n\n                functionDeclarations.push({\n                  name,\n                  description: String(description || \"\"),\n                  parameters: normalizeSchema(schema),\n                });\n\n                toolDebugSummaries.push(\n                  `decl=${name},src=${source},hasSchema=${schema ? \"y\" : \"n\"}`,\n                );\n              };\n\n              if (Array.isArray(tool.functionDeclarations) && tool.functionDeclarations.length > 0) {\n                tool.functionDeclarations.forEach((decl: any) => pushDeclaration(decl, \"functionDeclarations\"));\n                return;\n              }\n\n              // Fall back to function/custom style definitions.\n              if (\n                tool.function ||\n                tool.custom ||\n                tool.parameters ||\n                tool.input_schema ||\n                tool.inputSchema\n              ) {\n                pushDeclaration(tool.function ?? tool.custom ?? tool, \"function/custom\");\n                return;\n              }\n\n              // Preserve any non-function tool entries (e.g., codeExecution) untouched.\n              passthroughTools.push(tool);\n            });\n\n            const finalTools: any[] = [];\n            if (functionDeclarations.length > 0) {\n              finalTools.push({ functionDeclarations });\n            }\n            requestPayload.tools = finalTools.concat(passthroughTools);\n          } else {\n            // Gemini-specific tool normalization and feature injection\n            const geminiResult = applyGeminiTransforms(requestPayload, {\n              model: effectiveModel,\n              normalizedThinking: undefined, // Thinking config already applied above (lines 816-880)\n              tierThinkingBudget,\n              tierThinkingLevel: tierThinkingLevel as ThinkingTier | undefined,\n            });\n\n            toolDebugMissing = geminiResult.toolDebugMissing;\n            toolDebugSummaries.push(...geminiResult.toolDebugSummaries);\n          }\n\n          try {\n            toolDebugPayload = JSON.stringify(requestPayload.tools);\n          } catch {\n            toolDebugPayload = undefined;\n          }\n\n          // Apply Claude tool hardening (ported from LLM-API-Key-Proxy)\n          // Injects parameter signatures into descriptions and adds system instruction\n          // Can be disabled via config.claude_tool_hardening = false to reduce context size\n          const enableToolHardening = options?.claudeToolHardening ?? true;\n          if (enableToolHardening && isClaude && Array.isArray(requestPayload.tools) && requestPayload.tools.length > 0) {\n            // Inject parameter signatures into tool descriptions\n            requestPayload.tools = injectParameterSignatures(\n              requestPayload.tools,\n              CLAUDE_DESCRIPTION_PROMPT,\n            );\n\n            // Inject tool hardening system instruction\n            injectToolHardeningInstruction(\n              requestPayload as Record<string, unknown>,\n              CLAUDE_TOOL_SYSTEM_INSTRUCTION,\n            );\n          }\n        }\n\n        const conversationKey = resolveConversationKey(requestPayload);\n        signatureSessionKey = buildSignatureSessionKey(PLUGIN_SESSION_ID, effectiveModel, conversationKey, resolveProjectKey(projectId));\n\n        // For Claude models, filter out unsigned thinking blocks (required by Claude API)\n        // Attempts to restore signatures from cache for multi-turn conversations\n        // Handle both Gemini-style contents[] and Anthropic-style messages[] payloads.\n        if (isClaude) {\n          // Step 0: Sanitize cross-model metadata (strips Gemini signatures when sending to Claude)\n          sanitizeCrossModelPayloadInPlace(requestPayload, { targetModel: effectiveModel });\n\n          // Step 1: Strip corrupted/unsigned thinking blocks FIRST\n          deepFilterThinkingBlocks(requestPayload, signatureSessionKey, getCachedSignature, true);\n\n          if (enableClaudePromptAutoCaching && requestPayload.cache_control === undefined) {\n            requestPayload.cache_control = { type: \"ephemeral\" };\n          }\n\n          // Step 2: THEN inject signed thinking from cache (after stripping)\n          if (isClaudeThinking && keepThinkingEnabled && Array.isArray(requestPayload.contents)) {\n            requestPayload.contents = ensureThinkingBeforeToolUseInContents(requestPayload.contents, signatureSessionKey);\n          }\n          if (isClaudeThinking && keepThinkingEnabled && Array.isArray(requestPayload.messages)) {\n            requestPayload.messages = ensureThinkingBeforeToolUseInMessages(requestPayload.messages, signatureSessionKey);\n          }\n\n          // Step 3: Check if warmup needed (AFTER injection attempt)\n          if (isClaudeThinking && keepThinkingEnabled) {\n            const hasToolUse =\n              (Array.isArray(requestPayload.contents) && hasToolUseInContents(requestPayload.contents)) ||\n              (Array.isArray(requestPayload.messages) && hasToolUseInMessages(requestPayload.messages));\n            const hasSignedThinking =\n              (Array.isArray(requestPayload.contents) && hasSignedThinkingInContents(requestPayload.contents, signatureSessionKey)) ||\n              (Array.isArray(requestPayload.messages) && hasSignedThinkingInMessages(requestPayload.messages, signatureSessionKey));\n            const hasCachedThinking = defaultSignatureStore.has(signatureSessionKey);\n            needsSignedThinkingWarmup = hasToolUse && !hasSignedThinking && !hasCachedThinking;\n          }\n        }\n\n        // For Claude models, ensure functionCall/tool use parts carry IDs (required by Anthropic).\n        // We use a two-pass approach: first collect all functionCalls and assign IDs,\n        // then match functionResponses to their corresponding calls using a FIFO queue per function name.\n        if (isClaude && Array.isArray(requestPayload.contents)) {\n          let toolCallCounter = 0;\n          // Track pending call IDs per function name as a FIFO queue\n          const pendingCallIdsByName = new Map<string, string[]>();\n\n          // First pass: assign IDs to all functionCalls and collect them\n          requestPayload.contents = requestPayload.contents.map((content: any) => {\n            if (!content || !Array.isArray(content.parts)) {\n              return content;\n            }\n\n            const newParts = content.parts.map((part: any) => {\n              if (part && typeof part === \"object\" && part.functionCall) {\n                const call = { ...part.functionCall };\n                if (!call.id) {\n                  call.id = `tool-call-${++toolCallCounter}`;\n                }\n                const nameKey = typeof call.name === \"string\" ? call.name : `tool-${toolCallCounter}`;\n                // Push to the queue for this function name\n                const queue = pendingCallIdsByName.get(nameKey) || [];\n                queue.push(call.id);\n                pendingCallIdsByName.set(nameKey, queue);\n                return { ...part, functionCall: call };\n              }\n              return part;\n            });\n\n            return { ...content, parts: newParts };\n          });\n\n          // Second pass: match functionResponses to their corresponding calls (FIFO order)\n          requestPayload.contents = (requestPayload.contents as any[]).map((content: any) => {\n            if (!content || !Array.isArray(content.parts)) {\n              return content;\n            }\n\n            const newParts = content.parts.map((part: any) => {\n              if (part && typeof part === \"object\" && part.functionResponse) {\n                const resp = { ...part.functionResponse };\n                if (!resp.id && typeof resp.name === \"string\") {\n                  const queue = pendingCallIdsByName.get(resp.name);\n                  if (queue && queue.length > 0) {\n                    // Consume the first pending ID (FIFO order)\n                    resp.id = queue.shift();\n                    pendingCallIdsByName.set(resp.name, queue);\n                  }\n                }\n                return { ...part, functionResponse: resp };\n              }\n              return part;\n            });\n\n            return { ...content, parts: newParts };\n          });\n\n          // Third pass: Apply orphan recovery for mismatched tool IDs\n          // This handles cases where context compaction or other processes\n          // create ID mismatches between calls and responses.\n          // Ported from LLM-API-Key-Proxy's _fix_tool_response_grouping()\n          requestPayload.contents = fixToolResponseGrouping(requestPayload.contents as any[]);\n        }\n\n        // Fourth pass: Fix Claude format tool pairing (defense in depth)\n        // Handles orphaned tool_use blocks in Claude's messages[] format\n        if (Array.isArray(requestPayload.messages)) {\n          requestPayload.messages = validateAndFixClaudeToolPairing(requestPayload.messages);\n        }\n\n        // =====================================================================\n        // LAST RESORT RECOVERY: \"Let it crash and start again\"\n        // =====================================================================\n        // If after all our processing we're STILL in a bad state (tool loop without\n        // thinking at turn start), don't try to fix it - just close the turn and\n        // start fresh. This prevents permanent session breakage.\n        //\n        // This handles cases where:\n        // - Context compaction stripped thinking blocks\n        // - Signature cache miss\n        // - Any other corruption we couldn't repair\n        // - API error indicated thinking_block_order issue (forceThinkingRecovery=true)\n        //\n        // The synthetic messages allow Claude to generate fresh thinking on the\n        // new turn instead of failing with \"Expected thinking but found text\".\n        if (isClaudeThinking && Array.isArray(requestPayload.contents)) {\n          const conversationState = analyzeConversationState(requestPayload.contents);\n\n          // Force recovery if API returned thinking_block_order error (retry case)\n          // or if proactive check detects we need recovery\n          if (forceThinkingRecovery || needsThinkingRecovery(conversationState)) {\n            // Set message for toast notification (shown in plugin.ts, respects quiet mode)\n            thinkingRecoveryMessage = forceThinkingRecovery\n              ? \"Thinking recovery: retrying with fresh turn (API error)\"\n              : \"Thinking recovery: restarting turn (corrupted context)\";\n\n            requestPayload.contents = closeToolLoopForThinking(requestPayload.contents);\n\n            defaultSignatureStore.delete(signatureSessionKey);\n          }\n        }\n\n        if (\"model\" in requestPayload) {\n          delete requestPayload.model;\n        }\n\n        stripInjectedDebugFromRequestPayload(requestPayload);\n        sanitizeRequestPayloadForAntigravity(requestPayload);\n\n        const effectiveProjectId = projectId?.trim() || (headerStyle === \"antigravity\" ? generateSyntheticProjectId() : \"\");\n        resolvedProjectId = effectiveProjectId;\n\n        // Inject Antigravity system instruction with role \"user\" (CLIProxyAPI v6.6.89 compatibility)\n        // This sets request.systemInstruction.role = \"user\" and request.systemInstruction.parts[0].text\n        if (headerStyle === \"antigravity\") {\n          const existingSystemInstruction = requestPayload.systemInstruction;\n          if (existingSystemInstruction && typeof existingSystemInstruction === \"object\") {\n            const sys = existingSystemInstruction as Record<string, unknown>;\n            sys.role = \"user\";\n            if (Array.isArray(sys.parts) && sys.parts.length > 0) {\n              const firstPart = sys.parts[0] as Record<string, unknown>;\n              if (firstPart && typeof firstPart.text === \"string\") {\n                firstPart.text = ANTIGRAVITY_SYSTEM_INSTRUCTION + \"\\n\\n\" + firstPart.text;\n              } else {\n                sys.parts = [{ text: ANTIGRAVITY_SYSTEM_INSTRUCTION }, ...sys.parts];\n              }\n            } else {\n              sys.parts = [{ text: ANTIGRAVITY_SYSTEM_INSTRUCTION }];\n            }\n          } else if (typeof existingSystemInstruction === \"string\") {\n            requestPayload.systemInstruction = {\n              role: \"user\",\n              parts: [{ text: ANTIGRAVITY_SYSTEM_INSTRUCTION + \"\\n\\n\" + existingSystemInstruction }],\n            };\n          } else {\n            requestPayload.systemInstruction = {\n              role: \"user\",\n              parts: [{ text: ANTIGRAVITY_SYSTEM_INSTRUCTION }],\n            };\n          }\n        }\n\n        const wrappedBody: Record<string, unknown> = {\n          project: effectiveProjectId,\n          model: effectiveModel,\n          request: requestPayload,\n        };\n\n        if (headerStyle === \"antigravity\") {\n          wrappedBody.requestType = \"agent\";\n          wrappedBody.userAgent = \"antigravity\";\n          wrappedBody.requestId = \"agent-\" + crypto.randomUUID();\n        }\n        if (wrappedBody.request && typeof wrappedBody.request === 'object') {\n          // Use stable session ID for signature caching across multi-turn conversations\n          sessionId = signatureSessionKey;\n          (wrappedBody.request as any).sessionId = signatureSessionKey;\n        }\n\n        body = JSON.stringify(wrappedBody);\n      }\n    } catch (error) {\n      throw error;\n    }\n  }\n\n  if (streaming) {\n    headers.set(\"Accept\", \"text/event-stream\");\n  }\n\n  // Add interleaved thinking header for Claude thinking models\n  // This enables real-time streaming of thinking tokens\n  if (isClaudeThinking) {\n    const existing = headers.get(\"anthropic-beta\");\n    const interleavedHeader = \"interleaved-thinking-2025-05-14\";\n\n    if (existing) {\n      if (!existing.includes(interleavedHeader)) {\n        headers.set(\"anthropic-beta\", `${existing},${interleavedHeader}`);\n      }\n    } else {\n      headers.set(\"anthropic-beta\", interleavedHeader);\n    }\n  }\n\n  if (headerStyle === \"antigravity\") {\n    // Use randomized headers as the fallback pool for Antigravity mode\n    const selectedHeaders = getRandomizedHeaders(\"antigravity\", requestedModel);\n\n    // Antigravity mode: Match Antigravity Manager behavior\n    // AM only sends User-Agent on content requests — no X-Goog-Api-Client, no Client-Metadata header\n    // (ideType=ANTIGRAVITY goes in request body metadata via project.ts, not as a header)\n    const fingerprint = options?.fingerprint ?? getSessionFingerprint();\n    const fingerprintHeaders = buildFingerprintHeaders(fingerprint);\n\n    headers.set(\"User-Agent\", fingerprintHeaders[\"User-Agent\"] || selectedHeaders[\"User-Agent\"]);\n  } else {\n    // Gemini CLI mode: match opencode-gemini-auth Code Assist header set exactly\n    headers.set(\"User-Agent\", GEMINI_CLI_HEADERS[\"User-Agent\"]);\n    headers.set(\"X-Goog-Api-Client\", GEMINI_CLI_HEADERS[\"X-Goog-Api-Client\"]);\n    headers.set(\"Client-Metadata\", GEMINI_CLI_HEADERS[\"Client-Metadata\"]);\n  }\n  return {\n    request: transformedUrl,\n    init: {\n      ...baseInit,\n      headers,\n      body,\n    },\n    streaming,\n    requestedModel,\n    effectiveModel: effectiveModel,\n    projectId: resolvedProjectId,\n    endpoint: transformedUrl,\n    sessionId,\n    toolDebugMissing,\n    toolDebugSummary: toolDebugSummaries.slice(0, 20).join(\" | \"),\n    toolDebugPayload,\n    needsSignedThinkingWarmup,\n    headerStyle,\n    thinkingRecoveryMessage,\n  };\n}\n\nexport function buildThinkingWarmupBody(\n  bodyText: string | undefined,\n  isClaudeThinking: boolean,\n): string | null {\n  if (!bodyText || !isClaudeThinking) {\n    return null;\n  }\n\n  let parsed: Record<string, unknown>;\n  try {\n    parsed = JSON.parse(bodyText) as Record<string, unknown>;\n  } catch {\n    return null;\n  }\n\n  const warmupPrompt = \"Warmup request for thinking signature.\";\n\n  const updateRequest = (req: Record<string, unknown>) => {\n    req.contents = [{ role: \"user\", parts: [{ text: warmupPrompt }] }];\n    delete req.tools;\n    delete (req as any).toolConfig;\n\n    const generationConfig = (req.generationConfig ?? {}) as Record<string, unknown>;\n    generationConfig.thinkingConfig = {\n      include_thoughts: true,\n      thinking_budget: DEFAULT_THINKING_BUDGET,\n    };\n    generationConfig.maxOutputTokens = CLAUDE_THINKING_MAX_OUTPUT_TOKENS;\n    req.generationConfig = generationConfig;\n  };\n\n  if (parsed.request && typeof parsed.request === \"object\") {\n    updateRequest(parsed.request as Record<string, unknown>);\n    const nested = (parsed.request as any).request;\n    if (nested && typeof nested === \"object\") {\n      updateRequest(nested as Record<string, unknown>);\n    }\n  } else {\n    updateRequest(parsed);\n  }\n\n  return JSON.stringify(parsed);\n}\n\n/**\n * Normalizes Antigravity responses: applies retry headers, extracts cache usage into headers,\n * rewrites preview errors, flattens streaming payloads, and logs debug metadata.\n *\n * For streaming SSE responses, uses TransformStream for true real-time incremental streaming.\n * Thinking/reasoning tokens are transformed and forwarded immediately as they arrive.\n */\nexport async function transformAntigravityResponse(\n  response: Response,\n  streaming: boolean,\n  debugContext?: AntigravityDebugContext | null,\n  requestedModel?: string,\n  projectId?: string,\n  endpoint?: string,\n  effectiveModel?: string,\n  sessionId?: string,\n  toolDebugMissing?: number,\n  toolDebugSummary?: string,\n  toolDebugPayload?: string,\n  debugLines?: string[],\n): Promise<Response> {\n  const contentType = response.headers.get(\"content-type\") ?? \"\";\n  const isJsonResponse = contentType.includes(\"application/json\");\n  const isEventStreamResponse = contentType.includes(\"text/event-stream\");\n\n  // Generate text for thinking injection:\n  // - If debug=true: inject full debug logs\n  // - If keep_thinking=true (but no debug): inject placeholder to trigger signature caching\n  // Both use the same injection path (injectDebugThinking) for consistent behavior\n  const debugText =\n    isDebugTuiEnabled() && Array.isArray(debugLines) && debugLines.length > 0\n      ? formatDebugLinesForThinking(debugLines)\n      : getKeepThinking()\n        ? SYNTHETIC_THINKING_PLACEHOLDER\n        : undefined;\n  const cacheSignatures = shouldCacheThinkingSignatures(effectiveModel);\n\n  if (!isJsonResponse && !isEventStreamResponse) {\n    logAntigravityDebugResponse(debugContext, response, {\n      note: \"Non-JSON response (body omitted)\",\n    });\n    return response;\n  }\n\n  // For successful streaming responses, use TransformStream to transform SSE events\n  // while maintaining real-time streaming (no buffering of entire response).\n  // This enables thinking tokens to be displayed as they arrive, like the Codex plugin.\n  if (streaming && response.ok && isEventStreamResponse && response.body) {\n    const headers = new Headers(response.headers);\n\n    logAntigravityDebugResponse(debugContext, response, {\n      note: \"Streaming SSE response (real-time transform)\",\n    });\n\n    const streamingTransformer = createStreamingTransformer(\n      defaultSignatureStore,\n      {\n        onCacheSignature: cacheSignature,\n        onInjectDebug: injectDebugThinking,\n        // onInjectSyntheticThinking removed - keep_thinking now uses debugText path\n        transformThinkingParts,\n      },\n      {\n        signatureSessionKey: sessionId,\n        debugText,\n        cacheSignatures,\n        displayedThinkingHashes: effectiveModel && isGemini3Model(effectiveModel) ? sessionDisplayedThinkingHashes : undefined,\n        // injectSyntheticThinking removed - keep_thinking now unified with debug via debugText\n      },\n    );\n    return new Response(response.body.pipeThrough(streamingTransformer), {\n      status: response.status,\n      statusText: response.statusText,\n      headers,\n    });\n  }\n\n  const responseFallback = response.clone();\n\n  try {\n    const headers = new Headers(response.headers);\n    const text = await response.text();\n\n    if (!response.ok) {\n      let errorBody;\n      try {\n        errorBody = JSON.parse(text);\n      } catch {\n        errorBody = { error: { message: text } };\n      }\n\n      // Inject Debug Info\n      if (errorBody?.error) {\n        const rawErrorMessage =\n          typeof errorBody.error.message === \"string\" && errorBody.error.message.length > 0\n            ? errorBody.error.message\n            : \"Unknown error\";\n        const errorType = detectErrorType(rawErrorMessage);\n        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}` : \"\"}`;\n        const injectedDebug = debugText ? `\\n\\n${debugText}` : \"\";\n        errorBody.error.message = rawErrorMessage + debugInfo + injectedDebug;\n\n        // Check if this is a recoverable thinking error - throw to trigger retry\n        if (errorType === \"thinking_block_order\") {\n          const recoveryError = new Error(\"THINKING_RECOVERY_NEEDED\");\n          (recoveryError as any).recoveryType = errorType;\n          (recoveryError as any).originalError = errorBody;\n          (recoveryError as any).debugInfo = debugInfo;\n          throw recoveryError;\n        }\n\n        // Detect context length / prompt too long errors - signal to caller for toast\n        const errorMessage = errorBody.error.message?.toLowerCase() || \"\";\n        if (\n          errorMessage.includes(\"prompt is too long\") ||\n          errorMessage.includes(\"context length exceeded\") ||\n          errorMessage.includes(\"context_length_exceeded\") ||\n          errorMessage.includes(\"maximum context length\")\n        ) {\n          headers.set(\"x-antigravity-context-error\", \"prompt_too_long\");\n        }\n\n        // Detect tool pairing errors - signal to caller for toast\n        if (\n          errorMessage.includes(\"tool_use\") &&\n          errorMessage.includes(\"tool_result\") &&\n          (errorMessage.includes(\"without\") || errorMessage.includes(\"immediately after\"))\n        ) {\n          headers.set(\"x-antigravity-context-error\", \"tool_pairing\");\n        }\n\n        return new Response(JSON.stringify(errorBody), {\n          status: response.status,\n          statusText: response.statusText,\n          headers\n        });\n      }\n\n      if (errorBody?.error?.details && Array.isArray(errorBody.error.details)) {\n        const retryInfo = errorBody.error.details.find(\n          (detail: any) => detail['@type'] === 'type.googleapis.com/google.rpc.RetryInfo'\n        );\n\n        if (retryInfo?.retryDelay) {\n          const match = retryInfo.retryDelay.match(/^([\\d.]+)s$/);\n          if (match && match[1]) {\n            const retrySeconds = parseFloat(match[1]);\n            if (!isNaN(retrySeconds) && retrySeconds > 0) {\n              const retryAfterSec = Math.ceil(retrySeconds).toString();\n              const retryAfterMs = Math.ceil(retrySeconds * 1000).toString();\n              headers.set('Retry-After', retryAfterSec);\n              headers.set('retry-after-ms', retryAfterMs);\n            }\n          }\n        }\n      }\n    }\n\n    const init = {\n      status: response.status,\n      statusText: response.statusText,\n      headers,\n    };\n\n    const usageFromSse = streaming && isEventStreamResponse ? extractUsageFromSsePayload(text) : null;\n    const parsed: AntigravityApiBody | null = !streaming || !isEventStreamResponse ? parseAntigravityApiBody(text) : null;\n    const patched = parsed ? rewriteAntigravityPreviewAccessError(parsed, response.status, requestedModel) : null;\n    const effectiveBody = patched ?? parsed ?? undefined;\n\n    const usage = usageFromSse ?? (effectiveBody ? extractUsageMetadata(effectiveBody) : null);\n    \n    // Log cache stats when available\n    if (usage && effectiveModel) {\n      logCacheStats(\n        effectiveModel,\n        usage.cachedContentTokenCount ?? 0,\n        0, // API doesn't provide cache write tokens separately\n        usage.promptTokenCount ?? usage.totalTokenCount ?? 0,\n      );\n    }\n    \n    if (usage?.cachedContentTokenCount !== undefined) {\n      headers.set(\"x-antigravity-cached-content-token-count\", String(usage.cachedContentTokenCount));\n      if (usage.totalTokenCount !== undefined) {\n        headers.set(\"x-antigravity-total-token-count\", String(usage.totalTokenCount));\n      }\n      if (usage.promptTokenCount !== undefined) {\n        headers.set(\"x-antigravity-prompt-token-count\", String(usage.promptTokenCount));\n      }\n      if (usage.candidatesTokenCount !== undefined) {\n        headers.set(\"x-antigravity-candidates-token-count\", String(usage.candidatesTokenCount));\n      }\n    }\n\n    logAntigravityDebugResponse(debugContext, response, {\n      body: text,\n      note: streaming ? \"Streaming SSE payload (buffered fallback)\" : undefined,\n      headersOverride: headers,\n    });\n\n    // Note: successful streaming responses are handled above via TransformStream.\n    // This path only handles non-streaming responses or failed streaming responses.\n\n    if (!parsed) {\n      return new Response(text, init);\n    }\n\n    if (effectiveBody?.response !== undefined) {\n      let responseBody: unknown = effectiveBody.response;\n      // Inject thinking text (debug logs or \"[Thinking preserved]\" placeholder)\n      // Both debug=true and keep_thinking=true use the same path now\n      if (debugText) {\n        responseBody = injectDebugThinking(responseBody, debugText);\n      }\n      const transformed = transformThinkingParts(responseBody);\n      return new Response(JSON.stringify(transformed), init);\n    }\n\n    if (patched) {\n      return new Response(JSON.stringify(patched), init);\n    }\n\n    return new Response(text, init);\n  } catch (error) {\n    if (error instanceof Error && error.message === \"THINKING_RECOVERY_NEEDED\") {\n      throw error;\n    }\n\n    logAntigravityDebugResponse(debugContext, response, {\n      error,\n      note: \"Failed to transform Antigravity response\",\n    });\n    return responseFallback;\n  }\n}\n\nexport const __testExports = {\n  buildSignatureSessionKey,\n  hashConversationSeed,\n  extractTextFromContent,\n  extractConversationSeedFromMessages,\n  extractConversationSeedFromContents,\n  resolveConversationKey,\n  resolveProjectKey,\n  isGeminiToolUsePart,\n  isGeminiThinkingPart,\n  ensureThoughtSignature,\n  hasSignedThinkingPart,\n  hasSignedThinkingInContents,\n  hasSignedThinkingInMessages,\n  hasToolUseInContents,\n  hasToolUseInMessages,\n  ensureThinkingBeforeToolUseInContents,\n  ensureThinkingBeforeToolUseInMessages,\n  generateSyntheticProjectId,\n  MIN_SIGNATURE_LENGTH,\n  transformSseLine,\n  transformStreamingPayload,\n  createStreamingTransformer,\n};\n"
  },
  {
    "path": "src/plugin/rotation.test.ts",
    "content": "import { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport {\n  HealthScoreTracker,\n  TokenBucketTracker,\n  addJitter,\n  randomDelay,\n  sortByLruWithHealth,\n  selectHybridAccount,\n  type AccountWithMetrics,\n} from \"./rotation\";\n\ndescribe(\"HealthScoreTracker\", () => {\n  beforeEach(() => {\n    vi.useRealTimers();\n  });\n\n  describe(\"initial state\", () => {\n    it(\"returns initial score for unknown account\", () => {\n      const tracker = new HealthScoreTracker();\n      expect(tracker.getScore(0)).toBe(70);\n    });\n\n    it(\"uses custom initial score from config\", () => {\n      const tracker = new HealthScoreTracker({ initial: 50 });\n      expect(tracker.getScore(0)).toBe(50);\n    });\n\n    it(\"isUsable returns true for new accounts\", () => {\n      const tracker = new HealthScoreTracker();\n      expect(tracker.isUsable(0)).toBe(true);\n    });\n\n    it(\"getConsecutiveFailures returns 0 for unknown account\", () => {\n      const tracker = new HealthScoreTracker();\n      expect(tracker.getConsecutiveFailures(0)).toBe(0);\n    });\n  });\n\n  describe(\"recordSuccess\", () => {\n    it(\"increases score by success reward\", () => {\n      const tracker = new HealthScoreTracker({ initial: 70, successReward: 5 });\n      tracker.recordSuccess(0);\n      expect(tracker.getScore(0)).toBe(75);\n    });\n\n    it(\"caps score at maxScore\", () => {\n      const tracker = new HealthScoreTracker({ initial: 98, successReward: 5, maxScore: 100 });\n      tracker.recordSuccess(0);\n      expect(tracker.getScore(0)).toBe(100);\n    });\n\n    it(\"resets consecutive failures\", () => {\n      const tracker = new HealthScoreTracker();\n      tracker.recordRateLimit(0);\n      tracker.recordRateLimit(0);\n      expect(tracker.getConsecutiveFailures(0)).toBe(2);\n      \n      tracker.recordSuccess(0);\n      expect(tracker.getConsecutiveFailures(0)).toBe(0);\n    });\n  });\n\n  describe(\"recordRateLimit\", () => {\n    it(\"decreases score by rate limit penalty\", () => {\n      const tracker = new HealthScoreTracker({ initial: 70, rateLimitPenalty: -10 });\n      tracker.recordRateLimit(0);\n      expect(tracker.getScore(0)).toBe(60);\n    });\n\n    it(\"does not go below 0\", () => {\n      const tracker = new HealthScoreTracker({ initial: 5, rateLimitPenalty: -10 });\n      tracker.recordRateLimit(0);\n      expect(tracker.getScore(0)).toBe(0);\n    });\n\n    it(\"increments consecutive failures\", () => {\n      const tracker = new HealthScoreTracker();\n      tracker.recordRateLimit(0);\n      expect(tracker.getConsecutiveFailures(0)).toBe(1);\n      \n      tracker.recordRateLimit(0);\n      expect(tracker.getConsecutiveFailures(0)).toBe(2);\n    });\n  });\n\n  describe(\"recordFailure\", () => {\n    it(\"decreases score by failure penalty\", () => {\n      const tracker = new HealthScoreTracker({ initial: 70, failurePenalty: -20 });\n      tracker.recordFailure(0);\n      expect(tracker.getScore(0)).toBe(50);\n    });\n\n    it(\"does not go below 0\", () => {\n      const tracker = new HealthScoreTracker({ initial: 10, failurePenalty: -20 });\n      tracker.recordFailure(0);\n      expect(tracker.getScore(0)).toBe(0);\n    });\n\n    it(\"increments consecutive failures\", () => {\n      const tracker = new HealthScoreTracker();\n      tracker.recordFailure(0);\n      expect(tracker.getConsecutiveFailures(0)).toBe(1);\n    });\n  });\n\n  describe(\"isUsable\", () => {\n    it(\"returns true when score >= minUsable\", () => {\n      const tracker = new HealthScoreTracker({ initial: 50, minUsable: 50 });\n      expect(tracker.isUsable(0)).toBe(true);\n    });\n\n    it(\"returns false when score < minUsable\", () => {\n      const tracker = new HealthScoreTracker({ initial: 49, minUsable: 50 });\n      expect(tracker.isUsable(0)).toBe(false);\n    });\n\n    it(\"becomes unusable after multiple failures\", () => {\n      const tracker = new HealthScoreTracker({ initial: 70, failurePenalty: -20, minUsable: 50 });\n      tracker.recordFailure(0);\n      expect(tracker.isUsable(0)).toBe(true);\n      \n      tracker.recordFailure(0);\n      expect(tracker.isUsable(0)).toBe(false);\n    });\n  });\n\n  describe(\"time-based recovery\", () => {\n    it(\"recovers points over time\", () => {\n      let mockTime = 0;\n      vi.spyOn(Date, 'now').mockImplementation(() => mockTime);\n\n      const tracker = new HealthScoreTracker({ \n        initial: 70, \n        failurePenalty: -20, \n        recoveryRatePerHour: 10 \n      });\n      \n      tracker.recordFailure(0);\n      expect(tracker.getScore(0)).toBe(50);\n\n      mockTime = 2 * 60 * 60 * 1000;\n      expect(tracker.getScore(0)).toBe(70);\n\n      vi.restoreAllMocks();\n    });\n\n    it(\"caps recovery at maxScore\", () => {\n      let mockTime = 0;\n      vi.spyOn(Date, 'now').mockImplementation(() => mockTime);\n\n      const tracker = new HealthScoreTracker({ \n        initial: 90, \n        successReward: 5,\n        recoveryRatePerHour: 20,\n        maxScore: 100 \n      });\n      \n      tracker.recordSuccess(0);\n      expect(tracker.getScore(0)).toBe(95);\n      \n      mockTime = 60 * 60 * 1000;\n      expect(tracker.getScore(0)).toBe(100);\n\n      vi.restoreAllMocks();\n    });\n\n    it(\"floors recovered points (no partial points)\", () => {\n      let mockTime = 0;\n      vi.spyOn(Date, 'now').mockImplementation(() => mockTime);\n\n      const tracker = new HealthScoreTracker({ \n        initial: 70, \n        failurePenalty: -10, \n        recoveryRatePerHour: 2 \n      });\n      \n      tracker.recordFailure(0);\n      expect(tracker.getScore(0)).toBe(60);\n\n      mockTime = 20 * 60 * 1000;\n      expect(tracker.getScore(0)).toBe(60);\n\n      mockTime = 30 * 60 * 1000;\n      expect(tracker.getScore(0)).toBe(61);\n\n      vi.restoreAllMocks();\n    });\n  });\n\n  describe(\"reset\", () => {\n    it(\"clears health state for account\", () => {\n      const tracker = new HealthScoreTracker({ initial: 70 });\n      tracker.recordSuccess(0);\n      tracker.reset(0);\n      \n      expect(tracker.getScore(0)).toBe(70);\n      expect(tracker.getConsecutiveFailures(0)).toBe(0);\n    });\n  });\n\n  describe(\"getSnapshot\", () => {\n    it(\"returns current state of all tracked accounts\", () => {\n      const tracker = new HealthScoreTracker({ initial: 70, failurePenalty: -10 });\n      tracker.recordSuccess(0);\n      tracker.recordFailure(1);\n      tracker.recordFailure(1);\n      \n      const snapshot = tracker.getSnapshot();\n      expect(snapshot.get(0)?.score).toBe(71);\n      expect(snapshot.get(0)?.consecutiveFailures).toBe(0);\n      expect(snapshot.get(1)?.score).toBe(50);\n      expect(snapshot.get(1)?.consecutiveFailures).toBe(2);\n    });\n  });\n});\n\ndescribe(\"TokenBucketTracker\", () => {\n  beforeEach(() => {\n    vi.useRealTimers();\n  });\n\n  describe(\"initial state\", () => {\n    it(\"returns initial tokens for unknown account\", () => {\n      const tracker = new TokenBucketTracker();\n      expect(tracker.getTokens(0)).toBe(50);\n    });\n\n    it(\"uses custom initial tokens from config\", () => {\n      const tracker = new TokenBucketTracker({ initialTokens: 30 });\n      expect(tracker.getTokens(0)).toBe(30);\n    });\n\n    it(\"hasTokens returns true for new accounts\", () => {\n      const tracker = new TokenBucketTracker();\n      expect(tracker.hasTokens(0)).toBe(true);\n    });\n\n    it(\"getMaxTokens returns configured max tokens\", () => {\n      const tracker = new TokenBucketTracker({ maxTokens: 100 });\n      expect(tracker.getMaxTokens()).toBe(100);\n    });\n\n    it(\"getMaxTokens returns default when not configured\", () => {\n      const tracker = new TokenBucketTracker();\n      expect(tracker.getMaxTokens()).toBe(50);\n    });\n  });\n\n  describe(\"consume\", () => {\n    it(\"reduces token balance\", () => {\n      const tracker = new TokenBucketTracker({ initialTokens: 50 });\n      expect(tracker.consume(0, 1)).toBe(true);\n      // Use toBeCloseTo to handle floating point from micro-regeneration between calls\n      expect(tracker.getTokens(0)).toBeCloseTo(49, 2);\n    });\n\n    it(\"returns false when insufficient tokens\", () => {\n      const tracker = new TokenBucketTracker({ initialTokens: 5 });\n      expect(tracker.consume(0, 10)).toBe(false);\n      expect(tracker.getTokens(0)).toBe(5);\n    });\n\n    it(\"allows consuming exact remaining tokens\", () => {\n      const tracker = new TokenBucketTracker({ initialTokens: 10 });\n      expect(tracker.consume(0, 10)).toBe(true);\n      // Use toBeCloseTo to handle floating point from micro-regeneration between calls\n      expect(tracker.getTokens(0)).toBeCloseTo(0, 2);\n    });\n\n    it(\"handles multiple consumes\", () => {\n      const tracker = new TokenBucketTracker({ initialTokens: 50 });\n      tracker.consume(0, 10);\n      tracker.consume(0, 10);\n      tracker.consume(0, 10);\n      expect(tracker.getTokens(0)).toBe(20);\n    });\n  });\n\n  describe(\"hasTokens\", () => {\n    it(\"returns true when enough tokens\", () => {\n      const tracker = new TokenBucketTracker({ initialTokens: 50 });\n      expect(tracker.hasTokens(0, 50)).toBe(true);\n    });\n\n    it(\"returns false when insufficient tokens\", () => {\n      const tracker = new TokenBucketTracker({ initialTokens: 10 });\n      expect(tracker.hasTokens(0, 11)).toBe(false);\n    });\n\n    it(\"defaults to cost of 1\", () => {\n      const tracker = new TokenBucketTracker({ initialTokens: 1 });\n      expect(tracker.hasTokens(0)).toBe(true);\n      \n      tracker.consume(0, 1);\n      expect(tracker.hasTokens(0)).toBe(false);\n    });\n  });\n\n  describe(\"refund\", () => {\n    it(\"adds tokens back\", () => {\n      const tracker = new TokenBucketTracker({ initialTokens: 50 });\n      tracker.consume(0, 10);\n      expect(tracker.getTokens(0)).toBeCloseTo(40, 2);\n      \n      tracker.refund(0, 5);\n      expect(tracker.getTokens(0)).toBeCloseTo(45, 2);\n    });\n\n    it(\"caps at maxTokens\", () => {\n      const tracker = new TokenBucketTracker({ initialTokens: 50, maxTokens: 50 });\n      tracker.refund(0, 10);\n      expect(tracker.getTokens(0)).toBe(50);\n    });\n  });\n\n  describe(\"token regeneration\", () => {\n    it(\"regenerates tokens over time\", () => {\n      let mockTime = 0;\n      vi.spyOn(Date, 'now').mockImplementation(() => mockTime);\n\n      const tracker = new TokenBucketTracker({ \n        initialTokens: 50, \n        maxTokens: 50,\n        regenerationRatePerMinute: 6 \n      });\n      \n      tracker.consume(0, 30);\n      expect(tracker.getTokens(0)).toBe(20);\n\n      mockTime = 5 * 60 * 1000;\n      expect(tracker.getTokens(0)).toBe(50);\n\n      vi.restoreAllMocks();\n    });\n\n    it(\"caps regeneration at maxTokens\", () => {\n      let mockTime = 0;\n      vi.spyOn(Date, 'now').mockImplementation(() => mockTime);\n\n      const tracker = new TokenBucketTracker({ \n        initialTokens: 40, \n        maxTokens: 50,\n        regenerationRatePerMinute: 6 \n      });\n      \n      tracker.consume(0, 1);\n      \n      mockTime = 10 * 60 * 1000;\n      expect(tracker.getTokens(0)).toBe(50);\n\n      vi.restoreAllMocks();\n    });\n  });\n});\n\ndescribe(\"addJitter\", () => {\n  it(\"returns value within jitter range\", () => {\n    const base = 1000;\n    const jitterFactor = 0.3;\n    \n    for (let i = 0; i < 100; i++) {\n      const result = addJitter(base, jitterFactor);\n      expect(result).toBeGreaterThanOrEqual(base * (1 - jitterFactor));\n      expect(result).toBeLessThanOrEqual(base * (1 + jitterFactor));\n    }\n  });\n\n  it(\"uses default jitter factor of 0.3\", () => {\n    const base = 1000;\n    \n    for (let i = 0; i < 100; i++) {\n      const result = addJitter(base);\n      expect(result).toBeGreaterThanOrEqual(700);\n      expect(result).toBeLessThanOrEqual(1300);\n    }\n  });\n\n  it(\"never returns negative values\", () => {\n    for (let i = 0; i < 100; i++) {\n      const result = addJitter(10, 0.9);\n      expect(result).toBeGreaterThanOrEqual(0);\n    }\n  });\n\n  it(\"returns rounded values\", () => {\n    for (let i = 0; i < 100; i++) {\n      const result = addJitter(1000);\n      expect(Number.isInteger(result)).toBe(true);\n    }\n  });\n});\n\ndescribe(\"randomDelay\", () => {\n  it(\"returns value within min-max range\", () => {\n    for (let i = 0; i < 100; i++) {\n      const result = randomDelay(100, 500);\n      expect(result).toBeGreaterThanOrEqual(100);\n      expect(result).toBeLessThanOrEqual(500);\n    }\n  });\n\n  it(\"returns rounded values\", () => {\n    for (let i = 0; i < 100; i++) {\n      const result = randomDelay(100, 500);\n      expect(Number.isInteger(result)).toBe(true);\n    }\n  });\n\n  it(\"handles min === max\", () => {\n    const result = randomDelay(100, 100);\n    expect(result).toBe(100);\n  });\n});\n\ndescribe(\"sortByLruWithHealth\", () => {\n  it(\"filters out rate-limited accounts\", () => {\n    const accounts: AccountWithMetrics[] = [\n      { index: 0, lastUsed: 0, healthScore: 70, isRateLimited: true, isCoolingDown: false },\n      { index: 1, lastUsed: 0, healthScore: 70, isRateLimited: false, isCoolingDown: false },\n    ];\n\n    const result = sortByLruWithHealth(accounts);\n    expect(result).toHaveLength(1);\n    expect(result[0]?.index).toBe(1);\n  });\n\n  it(\"filters out cooling down accounts\", () => {\n    const accounts: AccountWithMetrics[] = [\n      { index: 0, lastUsed: 0, healthScore: 70, isRateLimited: false, isCoolingDown: true },\n      { index: 1, lastUsed: 0, healthScore: 70, isRateLimited: false, isCoolingDown: false },\n    ];\n\n    const result = sortByLruWithHealth(accounts);\n    expect(result).toHaveLength(1);\n    expect(result[0]?.index).toBe(1);\n  });\n\n  it(\"filters out unhealthy accounts\", () => {\n    const accounts: AccountWithMetrics[] = [\n      { index: 0, lastUsed: 0, healthScore: 40, isRateLimited: false, isCoolingDown: false },\n      { index: 1, lastUsed: 0, healthScore: 70, isRateLimited: false, isCoolingDown: false },\n    ];\n\n    const result = sortByLruWithHealth(accounts, 50);\n    expect(result).toHaveLength(1);\n    expect(result[0]?.index).toBe(1);\n  });\n\n  it(\"sorts by lastUsed ascending (oldest first)\", () => {\n    const accounts: AccountWithMetrics[] = [\n      { index: 0, lastUsed: 1000, healthScore: 70, isRateLimited: false, isCoolingDown: false },\n      { index: 1, lastUsed: 500, healthScore: 70, isRateLimited: false, isCoolingDown: false },\n      { index: 2, lastUsed: 2000, healthScore: 70, isRateLimited: false, isCoolingDown: false },\n    ];\n\n    const result = sortByLruWithHealth(accounts);\n    expect(result.map(a => a.index)).toEqual([1, 0, 2]);\n  });\n\n  it(\"uses health score as tiebreaker\", () => {\n    const accounts: AccountWithMetrics[] = [\n      { index: 0, lastUsed: 1000, healthScore: 60, isRateLimited: false, isCoolingDown: false },\n      { index: 1, lastUsed: 1000, healthScore: 80, isRateLimited: false, isCoolingDown: false },\n      { index: 2, lastUsed: 1000, healthScore: 70, isRateLimited: false, isCoolingDown: false },\n    ];\n\n    const result = sortByLruWithHealth(accounts);\n    expect(result.map(a => a.index)).toEqual([1, 2, 0]);\n  });\n\n  it(\"returns empty array when all accounts filtered out\", () => {\n    const accounts: AccountWithMetrics[] = [\n      { index: 0, lastUsed: 0, healthScore: 30, isRateLimited: false, isCoolingDown: false },\n      { index: 1, lastUsed: 0, healthScore: 70, isRateLimited: true, isCoolingDown: false },\n    ];\n\n    const result = sortByLruWithHealth(accounts, 50);\n    expect(result).toHaveLength(0);\n  });\n});\n\ndescribe(\"selectHybridAccount\", () => {\n  it(\"returns null when no accounts available\", () => {\n    const tokenTracker = new TokenBucketTracker();\n    const result = selectHybridAccount([], tokenTracker);\n    expect(result).toBeNull();\n  });\n\n  it(\"returns null when all accounts filtered out by health\", () => {\n    const tokenTracker = new TokenBucketTracker();\n    const accounts: AccountWithMetrics[] = [\n      { index: 0, lastUsed: 0, healthScore: 30, isRateLimited: false, isCoolingDown: false },\n    ];\n\n    const result = selectHybridAccount(accounts, tokenTracker, 50);\n    expect(result).toBeNull();\n  });\n\n  it(\"returns the best candidate by score\", () => {\n    const tokenTracker = new TokenBucketTracker();\n    const accounts: AccountWithMetrics[] = [\n      { index: 0, lastUsed: 1000, healthScore: 70, isRateLimited: false, isCoolingDown: false },\n      { index: 1, lastUsed: 500, healthScore: 70, isRateLimited: false, isCoolingDown: false },\n      { index: 2, lastUsed: 2000, healthScore: 70, isRateLimited: false, isCoolingDown: false },\n    ];\n\n    const result = selectHybridAccount(accounts, tokenTracker);\n    expect([0, 1, 2]).toContain(result);\n  });\n\n  it(\"filters out rate-limited accounts\", () => {\n    const tokenTracker = new TokenBucketTracker();\n    const accounts: AccountWithMetrics[] = [\n      { index: 0, lastUsed: 0, healthScore: 70, isRateLimited: true, isCoolingDown: false },\n      { index: 1, lastUsed: 0, healthScore: 70, isRateLimited: false, isCoolingDown: false },\n    ];\n\n    const result = selectHybridAccount(accounts, tokenTracker);\n    expect(result).toBe(1);\n  });\n\n  it(\"filters out accounts without tokens\", () => {\n    const tokenTracker = new TokenBucketTracker({ initialTokens: 1 });\n    tokenTracker.consume(0, 1);\n    \n    const accounts: AccountWithMetrics[] = [\n      { index: 0, lastUsed: 0, healthScore: 70, isRateLimited: false, isCoolingDown: false },\n      { index: 1, lastUsed: 0, healthScore: 70, isRateLimited: false, isCoolingDown: false },\n    ];\n\n    const result = selectHybridAccount(accounts, tokenTracker);\n    expect(result).toBe(1);\n  });\n\n  it(\"filters out unhealthy accounts\", () => {\n    const tokenTracker = new TokenBucketTracker();\n    const accounts: AccountWithMetrics[] = [\n      { index: 0, lastUsed: 0, healthScore: 40, isRateLimited: false, isCoolingDown: false },\n      { index: 1, lastUsed: 0, healthScore: 70, isRateLimited: false, isCoolingDown: false },\n    ];\n\n    const result = selectHybridAccount(accounts, tokenTracker, 50);\n    expect(result).toBe(1);\n  });\n\n  it(\"returns null when all accounts have no tokens\", () => {\n    const tokenTracker = new TokenBucketTracker({ initialTokens: 0 });\n    const accounts: AccountWithMetrics[] = [\n      { index: 0, lastUsed: 0, healthScore: 70, isRateLimited: false, isCoolingDown: false },\n    ];\n\n    const result = selectHybridAccount(accounts, tokenTracker);\n    expect(result).toBeNull();\n  });\n\n  it(\"selects only available candidate when one account is filtered\", () => {\n    const tokenTracker = new TokenBucketTracker({ initialTokens: 50 });\n    \n    const accounts: AccountWithMetrics[] = [\n      { index: 0, lastUsed: 0, healthScore: 40, isRateLimited: false, isCoolingDown: false },\n      { index: 1, lastUsed: 0, healthScore: 100, isRateLimited: false, isCoolingDown: false },\n    ];\n\n    const result = selectHybridAccount(accounts, tokenTracker, 50);\n    expect(result).toBe(1);\n  });\n\n  it(\"returns a valid account index\", () => {\n    const tokenTracker = new TokenBucketTracker();\n    const accounts: AccountWithMetrics[] = [\n      { index: 0, lastUsed: 1000, healthScore: 70, isRateLimited: false, isCoolingDown: false },\n      { index: 1, lastUsed: 500, healthScore: 80, isRateLimited: false, isCoolingDown: false },\n      { index: 2, lastUsed: 2000, healthScore: 60, isRateLimited: false, isCoolingDown: false },\n    ];\n\n    for (let i = 0; i < 10; i++) {\n      const result = selectHybridAccount(accounts, tokenTracker);\n      expect([0, 1, 2]).toContain(result);\n    }\n  });\n});\n"
  },
  {
    "path": "src/plugin/rotation.ts",
    "content": "/**\n * Account Rotation System\n * \n * Implements advanced account selection algorithms:\n * - Health Score: Track account wellness based on success/failure\n * - LRU Selection: Prefer accounts with longest rest periods\n * - Jitter: Add random variance to break predictable patterns\n * \n * Used by 'hybrid' strategy for improved ban prevention and load distribution.\n */\n\n// ============================================================================\n// HEALTH SCORE SYSTEM\n// ============================================================================\n\nexport interface HealthScoreConfig {\n  /** Initial score for new accounts (default: 70) */\n  initial: number;\n  /** Points added on successful request (default: 1) */\n  successReward: number;\n  /** Points removed on rate limit (default: -10) */\n  rateLimitPenalty: number;\n  /** Points removed on failure (auth, network, etc.) (default: -20) */\n  failurePenalty: number;\n  /** Points recovered per hour of rest (default: 2) */\n  recoveryRatePerHour: number;\n  /** Minimum score to be considered usable (default: 50) */\n  minUsable: number;\n  /** Maximum score cap (default: 100) */\n  maxScore: number;\n}\n\nexport const DEFAULT_HEALTH_SCORE_CONFIG: HealthScoreConfig = {\n  initial: 70,\n  successReward: 1,\n  rateLimitPenalty: -10,\n  failurePenalty: -20,\n  recoveryRatePerHour: 2,\n  minUsable: 50,\n  maxScore: 100,\n};\n\ninterface HealthScoreState {\n  score: number;\n  lastUpdated: number;\n  lastSuccess: number;\n  consecutiveFailures: number;\n}\n\n/**\n * Tracks health scores for accounts.\n * Higher score = healthier account = preferred for selection.\n */\nexport class HealthScoreTracker {\n  private readonly scores = new Map<number, HealthScoreState>();\n  private readonly config: HealthScoreConfig;\n\n  constructor(config: Partial<HealthScoreConfig> = {}) {\n    this.config = { ...DEFAULT_HEALTH_SCORE_CONFIG, ...config };\n  }\n\n  /**\n   * Get current health score for an account, applying time-based recovery.\n   */\n  getScore(accountIndex: number): number {\n    const state = this.scores.get(accountIndex);\n    if (!state) {\n      return this.config.initial;\n    }\n\n    // Apply passive recovery based on time since last update\n    const now = Date.now();\n    const hoursSinceUpdate = (now - state.lastUpdated) / (1000 * 60 * 60);\n    const recoveredPoints = Math.floor(hoursSinceUpdate * this.config.recoveryRatePerHour);\n    \n    return Math.min(\n      this.config.maxScore,\n      state.score + recoveredPoints\n    );\n  }\n\n  /**\n   * Record a successful request - improves health score.\n   */\n  recordSuccess(accountIndex: number): void {\n    const now = Date.now();\n    const current = this.getScore(accountIndex);\n    \n    this.scores.set(accountIndex, {\n      score: Math.min(this.config.maxScore, current + this.config.successReward),\n      lastUpdated: now,\n      lastSuccess: now,\n      consecutiveFailures: 0,\n    });\n  }\n\n  /**\n   * Record a rate limit hit - moderate penalty.\n   */\n  recordRateLimit(accountIndex: number): void {\n    const now = Date.now();\n    const state = this.scores.get(accountIndex);\n    const current = this.getScore(accountIndex);\n    \n    this.scores.set(accountIndex, {\n      score: Math.max(0, current + this.config.rateLimitPenalty),\n      lastUpdated: now,\n      lastSuccess: state?.lastSuccess ?? 0,\n      consecutiveFailures: (state?.consecutiveFailures ?? 0) + 1,\n    });\n  }\n\n  /**\n   * Record a failure (auth, network, etc.) - larger penalty.\n   */\n  recordFailure(accountIndex: number): void {\n    const now = Date.now();\n    const state = this.scores.get(accountIndex);\n    const current = this.getScore(accountIndex);\n    \n    this.scores.set(accountIndex, {\n      score: Math.max(0, current + this.config.failurePenalty),\n      lastUpdated: now,\n      lastSuccess: state?.lastSuccess ?? 0,\n      consecutiveFailures: (state?.consecutiveFailures ?? 0) + 1,\n    });\n  }\n\n  /**\n   * Check if account is healthy enough to use.\n   */\n  isUsable(accountIndex: number): boolean {\n    return this.getScore(accountIndex) >= this.config.minUsable;\n  }\n\n  /**\n   * Get consecutive failure count for an account.\n   */\n  getConsecutiveFailures(accountIndex: number): number {\n    return this.scores.get(accountIndex)?.consecutiveFailures ?? 0;\n  }\n\n  /**\n   * Reset health state for an account (e.g., after removal).\n   */\n  reset(accountIndex: number): void {\n    this.scores.delete(accountIndex);\n  }\n\n  /**\n   * Get all scores for debugging/logging.\n   */\n  getSnapshot(): Map<number, { score: number; consecutiveFailures: number }> {\n    const result = new Map<number, { score: number; consecutiveFailures: number }>();\n    for (const [index] of this.scores) {\n      result.set(index, {\n        score: this.getScore(index),\n        consecutiveFailures: this.getConsecutiveFailures(index),\n      });\n    }\n    return result;\n  }\n}\n\n// ============================================================================\n// JITTER UTILITIES\n// ============================================================================\n\n/**\n * Add random jitter to a delay value.\n * Helps break predictable timing patterns.\n * \n * @param baseMs - Base delay in milliseconds\n * @param jitterFactor - Fraction of base to vary (default: 0.3 = ±30%)\n * @returns Jittered delay in milliseconds\n */\nexport function addJitter(baseMs: number, jitterFactor: number = 0.3): number {\n  const jitterRange = baseMs * jitterFactor;\n  const jitter = (Math.random() * 2 - 1) * jitterRange; // -jitterRange to +jitterRange\n  return Math.max(0, Math.round(baseMs + jitter));\n}\n\n/**\n * Generate a random delay within a range.\n * \n * @param minMs - Minimum delay in milliseconds\n * @param maxMs - Maximum delay in milliseconds\n * @returns Random delay between min and max\n */\nexport function randomDelay(minMs: number, maxMs: number): number {\n  return Math.round(minMs + Math.random() * (maxMs - minMs));\n}\n\n// ============================================================================\n// LRU SELECTION\n// ============================================================================\n\nexport interface AccountWithMetrics {\n  index: number;\n  lastUsed: number;\n  healthScore: number;\n  isRateLimited: boolean;\n  isCoolingDown: boolean;\n}\n\n/**\n * Sort accounts by LRU (least recently used first) with health score tiebreaker.\n * \n * Priority:\n * 1. Filter out rate-limited and cooling-down accounts\n * 2. Filter out unhealthy accounts (score < minUsable)\n * 3. Sort by lastUsed ascending (oldest first = most rested)\n * 4. Tiebreaker: higher health score wins\n */\nexport function sortByLruWithHealth(\n  accounts: AccountWithMetrics[],\n  minHealthScore: number = 50,\n): AccountWithMetrics[] {\n  return accounts\n    .filter(acc => !acc.isRateLimited && !acc.isCoolingDown && acc.healthScore >= minHealthScore)\n    .sort((a, b) => {\n      // Primary: LRU (oldest lastUsed first)\n      const lruDiff = a.lastUsed - b.lastUsed;\n      if (lruDiff !== 0) return lruDiff;\n      \n      // Tiebreaker: higher health score wins\n      return b.healthScore - a.healthScore;\n    });\n}\n\n/** Stickiness bonus added to current account's score to prevent unnecessary switching */\nconst STICKINESS_BONUS = 150;\n\n/** Minimum score advantage required to switch away from current account */\nconst SWITCH_THRESHOLD = 100;\n\n/**\n * Select account using hybrid strategy with stickiness:\n * 1. Filter available accounts (not rate-limited, not cooling down, healthy, has tokens)\n * 2. Calculate priority score: health (2x) + tokens (5x) + freshness (0.1x)\n * 3. Apply stickiness bonus to current account\n * 4. Only switch if another account beats current by SWITCH_THRESHOLD\n * \n * @param accounts - All accounts with their metrics\n * @param tokenTracker - Token bucket tracker for token balances\n * @param currentAccountIndex - Currently active account index (for stickiness)\n * @param minHealthScore - Minimum health score to be considered\n * @returns Best account index, or null if none available\n */\nexport function selectHybridAccount(\n  accounts: AccountWithMetrics[],\n  tokenTracker: TokenBucketTracker,\n  currentAccountIndex: number | null = null,\n  minHealthScore: number = 50,\n): number | null {\n  const candidates = accounts\n    .filter(acc => \n      !acc.isRateLimited && \n      !acc.isCoolingDown && \n      acc.healthScore >= minHealthScore &&\n      tokenTracker.hasTokens(acc.index)\n    )\n    .map(acc => ({\n      ...acc,\n      tokens: tokenTracker.getTokens(acc.index)\n    }));\n\n  if (candidates.length === 0) {\n    return null;\n  }\n\n  const maxTokens = tokenTracker.getMaxTokens();\n  const scored = candidates\n    .map(acc => {\n      const baseScore = calculateHybridScore(acc, maxTokens);\n      // Apply stickiness bonus to current account\n      const stickinessBonus = acc.index === currentAccountIndex ? STICKINESS_BONUS : 0;\n      return {\n        index: acc.index,\n        baseScore,\n        score: baseScore + stickinessBonus,\n        isCurrent: acc.index === currentAccountIndex\n      };\n    })\n    .sort((a, b) => b.score - a.score);\n\n  const best = scored[0];\n  if (!best) {\n    return null;\n  }\n\n  // If current account is still a candidate, check if switch is warranted\n  const currentCandidate = scored.find(s => s.isCurrent);\n  if (currentCandidate && !best.isCurrent) {\n    // Only switch if best beats current's BASE score by threshold\n    // (compare base scores to avoid circular stickiness bonus comparison)\n    const advantage = best.baseScore - currentCandidate.baseScore;\n    if (advantage < SWITCH_THRESHOLD) {\n      return currentCandidate.index;\n    }\n  }\n\n  return best.index;\n}\n\ninterface AccountWithTokens extends AccountWithMetrics {\n  tokens: number;\n}\n\nfunction calculateHybridScore(\n  account: AccountWithTokens,\n  maxTokens: number\n): number {\n  const healthComponent = account.healthScore * 2; // 0-200\n  const tokenComponent = (account.tokens / maxTokens) * 100 * 5; // 0-500\n  const secondsSinceUsed = (Date.now() - account.lastUsed) / 1000;\n  const freshnessComponent = Math.min(secondsSinceUsed, 3600) * 0.1; // 0-360\n  return Math.max(0, healthComponent + tokenComponent + freshnessComponent);\n}\n\n// ============================================================================\n// TOKEN BUCKET SYSTEM\n// ============================================================================\n\nexport interface TokenBucketConfig {\n  /** Maximum tokens per account (default: 50) */\n  maxTokens: number;\n  /** Tokens regenerated per minute (default: 6) */\n  regenerationRatePerMinute: number;\n  /** Initial tokens for new accounts (default: 50) */\n  initialTokens: number;\n}\n\nexport const DEFAULT_TOKEN_BUCKET_CONFIG: TokenBucketConfig = {\n  maxTokens: 50,\n  regenerationRatePerMinute: 6,\n  initialTokens: 50,\n};\n\ninterface TokenBucketState {\n  tokens: number;\n  lastUpdated: number;\n}\n\n/**\n * Client-side rate limiting using Token Bucket algorithm.\n * Helps prevent hitting server 429s by tracking \"cost\" of requests.\n */\nexport class TokenBucketTracker {\n  private readonly buckets = new Map<number, TokenBucketState>();\n  private readonly config: TokenBucketConfig;\n\n  constructor(config: Partial<TokenBucketConfig> = {}) {\n    this.config = { ...DEFAULT_TOKEN_BUCKET_CONFIG, ...config };\n  }\n\n  /**\n   * Get current token balance for an account, applying regeneration.\n   */\n  getTokens(accountIndex: number): number {\n    const state = this.buckets.get(accountIndex);\n    if (!state) {\n      return this.config.initialTokens;\n    }\n\n    const now = Date.now();\n    const minutesSinceUpdate = (now - state.lastUpdated) / (1000 * 60);\n    const recoveredTokens = minutesSinceUpdate * this.config.regenerationRatePerMinute;\n    \n    return Math.min(\n      this.config.maxTokens,\n      state.tokens + recoveredTokens\n    );\n  }\n\n  /**\n   * Check if account has enough tokens for a request.\n   * @param cost Cost of the request (default: 1)\n   */\n  hasTokens(accountIndex: number, cost: number = 1): boolean {\n    return this.getTokens(accountIndex) >= cost;\n  }\n\n  /**\n   * Consume tokens for a request.\n   * @returns true if tokens were consumed, false if insufficient\n   */\n  consume(accountIndex: number, cost: number = 1): boolean {\n    const current = this.getTokens(accountIndex);\n    if (current < cost) {\n      return false;\n    }\n\n    this.buckets.set(accountIndex, {\n      tokens: current - cost,\n      lastUpdated: Date.now(),\n    });\n    return true;\n  }\n\n  /**\n   * Refund tokens (e.g., if request wasn't actually sent).\n   */\n  refund(accountIndex: number, amount: number = 1): void {\n    const current = this.getTokens(accountIndex);\n    this.buckets.set(accountIndex, {\n      tokens: Math.min(this.config.maxTokens, current + amount),\n      lastUpdated: Date.now(),\n    });\n  }\n\n  getMaxTokens(): number {\n    return this.config.maxTokens;\n  }\n}\n\n// ============================================================================\n// SINGLETON TRACKERS\n// ============================================================================\n\nlet globalTokenTracker: TokenBucketTracker | null = null;\n\nexport function getTokenTracker(): TokenBucketTracker {\n  if (!globalTokenTracker) {\n    globalTokenTracker = new TokenBucketTracker();\n  }\n  return globalTokenTracker;\n}\n\nexport function initTokenTracker(config: Partial<TokenBucketConfig>): TokenBucketTracker {\n  globalTokenTracker = new TokenBucketTracker(config);\n  return globalTokenTracker;\n}\n\nlet globalHealthTracker: HealthScoreTracker | null = null;\n\n/**\n * Get the global health score tracker instance.\n * Creates one with default config if not initialized.\n */\nexport function getHealthTracker(): HealthScoreTracker {\n  if (!globalHealthTracker) {\n    globalHealthTracker = new HealthScoreTracker();\n  }\n  return globalHealthTracker;\n}\n\n/**\n * Initialize the global health tracker with custom config.\n * Call this at plugin startup if custom config is needed.\n */\nexport function initHealthTracker(config: Partial<HealthScoreConfig>): HealthScoreTracker {\n  globalHealthTracker = new HealthScoreTracker(config);\n  return globalHealthTracker;\n}\n"
  },
  {
    "path": "src/plugin/search.ts",
    "content": "/**\n * Google Search Tool Implementation\n *\n * Due to Gemini API limitations, native search tools (googleSearch, urlContext)\n * cannot be combined with function declarations. This module implements a\n * wrapper that makes separate API calls with only the grounding tools enabled.\n */\n\nimport {\n  ANTIGRAVITY_ENDPOINT,\n  getAntigravityHeaders,\n  SEARCH_MODEL,\n  SEARCH_TIMEOUT_MS,\n  SEARCH_SYSTEM_INSTRUCTION,\n} from \"../constants\";\nimport { createLogger } from \"./logger\";\n\nconst log = createLogger(\"search\");\n\n// ============================================================================\n// Types\n// ============================================================================\n\ninterface GroundingChunk {\n  web?: {\n    uri?: string;\n    title?: string;\n  };\n}\n\ninterface GroundingSupport {\n  segment?: {\n    startIndex?: number;\n    endIndex?: number;\n    text?: string;\n  };\n  groundingChunkIndices?: number[];\n}\n\ninterface GroundingMetadata {\n  webSearchQueries?: string[];\n  groundingChunks?: GroundingChunk[];\n  groundingSupports?: GroundingSupport[];\n  searchEntryPoint?: {\n    renderedContent?: string;\n  };\n}\n\ninterface UrlMetadata {\n  retrieved_url?: string;\n  url_retrieval_status?: string;\n}\n\ninterface UrlContextMetadata {\n  url_metadata?: UrlMetadata[];\n}\n\ninterface SearchResponse {\n  candidates?: Array<{\n    content?: {\n      parts?: Array<{ text?: string }>;\n      role?: string;\n    };\n    finishReason?: string;\n    groundingMetadata?: GroundingMetadata;\n    urlContextMetadata?: UrlContextMetadata;\n  }>;\n  error?: {\n    code?: number;\n    message?: string;\n    status?: string;\n  };\n}\n\ninterface AntigravitySearchResponse {\n  response?: SearchResponse;\n  error?: {\n    code?: number;\n    message?: string;\n    status?: string;\n  };\n}\n\nexport interface SearchArgs {\n  query: string;\n  urls?: string[];\n  thinking?: boolean;\n}\n\nexport interface SearchResult {\n  text: string;\n  sources: Array<{ title: string; url: string }>;\n  searchQueries: string[];\n  urlsRetrieved: Array<{ url: string; status: string }>;\n}\n\n// ============================================================================\n// Helper Functions\n// ============================================================================\n\nlet sessionCounter = 0;\nconst sessionPrefix = `search-${Date.now().toString(36)}`;\n\nfunction generateRequestId(): string {\n  return `search-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;\n}\n\nfunction getSessionId(): string {\n  sessionCounter++;\n  return `${sessionPrefix}-${sessionCounter}`;\n}\n\nfunction formatSearchResult(result: SearchResult): string {\n  const lines: string[] = [];\n\n  lines.push(\"## Search Results\\n\");\n  lines.push(result.text);\n  lines.push(\"\");\n\n  if (result.sources.length > 0) {\n    lines.push(\"### Sources\");\n    for (const source of result.sources) {\n      lines.push(`- [${source.title}](${source.url})`);\n    }\n    lines.push(\"\");\n  }\n\n  if (result.urlsRetrieved.length > 0) {\n    lines.push(\"### URLs Retrieved\");\n    for (const url of result.urlsRetrieved) {\n      const status = url.status === \"URL_RETRIEVAL_STATUS_SUCCESS\" ? \"✓\" : \"✗\";\n      lines.push(`- ${status} ${url.url}`);\n    }\n    lines.push(\"\");\n  }\n\n  if (result.searchQueries.length > 0) {\n    lines.push(\"### Search Queries Used\");\n    for (const q of result.searchQueries) {\n      lines.push(`- \"${q}\"`);\n    }\n  }\n\n  return lines.join(\"\\n\");\n}\n\nfunction parseSearchResponse(data: AntigravitySearchResponse): SearchResult {\n  const result: SearchResult = {\n    text: \"\",\n    sources: [],\n    searchQueries: [],\n    urlsRetrieved: [],\n  };\n\n  const response = data.response;\n  if (!response || !response.candidates || response.candidates.length === 0) {\n    if (data.error) {\n      result.text = `Error: ${data.error.message ?? \"Unknown error\"}`;\n    } else if (response?.error) {\n      result.text = `Error: ${response.error.message ?? \"Unknown error\"}`;\n    }\n    return result;\n  }\n\n  const candidate = response.candidates[0];\n  if (!candidate) {\n    return result;\n  }\n\n  // Extract text content\n  if (candidate.content?.parts) {\n    result.text = candidate.content.parts\n      .map((p: { text?: string }) => p.text ?? \"\")\n      .filter(Boolean)\n      .join(\"\\n\");\n  }\n\n  // Extract grounding metadata\n  if (candidate.groundingMetadata) {\n    const gm = candidate.groundingMetadata;\n\n    if (gm.webSearchQueries) {\n      result.searchQueries = gm.webSearchQueries;\n    }\n\n    if (gm.groundingChunks) {\n      for (const chunk of gm.groundingChunks) {\n        if (chunk.web?.uri && chunk.web?.title) {\n          result.sources.push({\n            title: chunk.web.title,\n            url: chunk.web.uri,\n          });\n        }\n      }\n    }\n  }\n\n  // Extract URL context metadata\n  if (candidate.urlContextMetadata?.url_metadata) {\n    for (const meta of candidate.urlContextMetadata.url_metadata) {\n      if (meta.retrieved_url) {\n        result.urlsRetrieved.push({\n          url: meta.retrieved_url,\n          status: meta.url_retrieval_status ?? \"UNKNOWN\",\n        });\n      }\n    }\n  }\n\n  return result;\n}\n\n// ============================================================================\n// Main Search Function\n// ============================================================================\n\n/**\n * Execute a Google Search using the Gemini grounding API.\n *\n * This makes a SEPARATE API call with only googleSearch/urlContext tools,\n * which is required because these tools cannot be combined with function declarations.\n */\nexport async function executeSearch(\n  args: SearchArgs,\n  accessToken: string,\n  projectId: string,\n  abortSignal?: AbortSignal,\n): Promise<string> {\n  const { query, urls, thinking = true } = args;\n\n  // Build prompt with optional URLs\n  let prompt = query;\n  if (urls && urls.length > 0) {\n    const urlList = urls.join(\"\\n\");\n    prompt = `${query}\\n\\nURLs to analyze:\\n${urlList}`;\n  }\n\n  // Build tools array - only grounding tools, no function declarations\n  const tools: Array<Record<string, unknown>> = [];\n  tools.push({ googleSearch: {} });\n  if (urls && urls.length > 0) {\n    tools.push({ urlContext: {} });\n  }\n\n  const requestPayload = {\n    systemInstruction: {\n      parts: [{ text: SEARCH_SYSTEM_INSTRUCTION }],\n    },\n    contents: [\n      {\n        role: \"user\",\n        parts: [{ text: prompt }],\n      },\n    ],\n    tools,\n    generationConfig: {\n      temperature: 0,\n      topP: 1,\n    },\n  };\n\n  // Wrap in Antigravity format\n  const wrappedBody = {\n    project: projectId,\n    model: SEARCH_MODEL,\n    userAgent: \"antigravity\",\n    requestId: generateRequestId(),\n    request: {\n      ...requestPayload,\n      sessionId: getSessionId(),\n    },\n  };\n\n  // Use non-streaming endpoint for search\n  const url = `${ANTIGRAVITY_ENDPOINT}/v1internal:generateContent`;\n\n  log.debug(\"Executing search\", {\n    query,\n    urlCount: urls?.length ?? 0,\n    thinking,\n  });\n\n  try {\n    const response = await fetch(url, {\n      method: \"POST\",\n      headers: {\n        ...getAntigravityHeaders(),\n        Authorization: `Bearer ${accessToken}`,\n        \"Content-Type\": \"application/json\",\n      },\n      body: JSON.stringify(wrappedBody),\n      signal: abortSignal ?? AbortSignal.timeout(SEARCH_TIMEOUT_MS),\n    });\n\n    if (!response.ok) {\n      const errorText = await response.text();\n      log.debug(\"Search API error\", { status: response.status, error: errorText });\n      return `## Search Error\\n\\nFailed to execute search: ${response.status} ${response.statusText}\\n\\n${errorText}\\n\\nPlease try again with a different query.`;\n    }\n\n    const data = (await response.json()) as AntigravitySearchResponse;\n    log.debug(\"Search response received\", { hasResponse: !!data.response });\n\n    const result = parseSearchResponse(data);\n    const formatted = formatSearchResult(result);\n    log.debug(\"Search response formatted\", { resultLength: formatted.length });\n    return formatted;\n  } catch (error) {\n    const message = error instanceof Error ? error.message : String(error);\n    log.debug(\"Search execution error\", { error: message });\n    return `## Search Error\\n\\nFailed to execute search: ${message}. Please try again with a different query.`;\n  }\n}\n"
  },
  {
    "path": "src/plugin/server.ts",
    "content": "import { createServer } from \"node:http\";\nimport { readFileSync, existsSync } from \"node:fs\";\n\nimport { ANTIGRAVITY_REDIRECT_URI } from \"../constants\";\n\ninterface OAuthListenerOptions {\n  /**\n   * How long to wait for the OAuth redirect before timing out (in milliseconds).\n   */\n  timeoutMs?: number;\n}\n\nexport interface OAuthListener {\n  /**\n   * Resolves with the callback URL once Google redirects back to the local server.\n   */\n  waitForCallback(): Promise<URL>;\n  /**\n   * Cleanly stop listening for callbacks.\n   */\n  close(): Promise<void>;\n}\n\nconst redirectUri = new URL(ANTIGRAVITY_REDIRECT_URI);\nconst callbackPath = redirectUri.pathname || \"/\";\n\n/**\n * Detect if running in OrbStack Docker with --network host mode.\n * OrbStack's host networking only forwards ports bound to 127.0.0.1 to macOS.\n */\nfunction isOrbStackDockerHost(): boolean {\n  // Check if we're in Docker\n  if (!existsSync(\"/.dockerenv\")) {\n    return false;\n  }\n  \n  // Check for OrbStack-specific indicators\n  // OrbStack sets specific environment variables or has identifiable characteristics\n  try {\n    // OrbStack containers often have /run/.containerenv or specific mount patterns\n    // Also check if /proc/version contains orbstack\n    if (existsSync(\"/proc/version\")) {\n      const version = readFileSync(\"/proc/version\", \"utf8\").toLowerCase();\n      if (version.includes(\"orbstack\")) {\n        return true;\n      }\n    }\n    \n    // Check hostname pattern (OrbStack uses specific patterns)\n    const hostname = process.env.HOSTNAME || \"\";\n    if (hostname.startsWith(\"orbstack-\") || hostname.endsWith(\".orb\") || hostname === \"orbstack\") {\n      return true;\n    }\n    \n    // Check for OrbStack's network host mode by looking at resolv.conf\n    // OrbStack with --network host has specific DNS configuration\n    if (existsSync(\"/etc/resolv.conf\")) {\n      const resolv = readFileSync(\"/etc/resolv.conf\", \"utf8\");\n      if (resolv.includes(\"orb.local\") || resolv.includes(\"orbstack\")) {\n        return true;\n      }\n    }\n    \n    // Fallback: Check if running on macOS/Darwin host via Docker\n    // This is a heuristic - if in Docker on Linux but /proc/version shows darwin-like patterns\n    if (process.platform === \"linux\" && existsSync(\"/.dockerenv\")) {\n      // Most OrbStack containers will have been caught above\n      // For safety, also check common OrbStack mount patterns\n      if (existsSync(\"/run/host-services\")) {\n        return true;\n      }\n    }\n  } catch {\n    // Ignore errors, fall through to default\n  }\n  \n  return false;\n}\n\n/**\n * Detect WSL (Windows Subsystem for Linux) environment.\n */\nfunction isWSL(): boolean {\n  if (process.platform !== \"linux\") return false;\n  try {\n    const release = readFileSync(\"/proc/version\", \"utf8\").toLowerCase();\n    return release.includes(\"microsoft\") || release.includes(\"wsl\");\n  } catch {\n    return false;\n  }\n}\n\n/**\n * Detect remote/SSH environment where localhost may not be accessible from browser.\n */\nfunction isRemoteEnvironment(): boolean {\n  if (process.env.SSH_CLIENT || process.env.SSH_TTY || process.env.SSH_CONNECTION) {\n    return true;\n  }\n  if (process.env.REMOTE_CONTAINERS || process.env.CODESPACES) {\n    return true;\n  }\n  return false;\n}\n\n/**\n * Determine the best bind address for the OAuth callback server.\n * \n * Priority:\n * 1. OPENCODE_ANTIGRAVITY_OAUTH_BIND environment variable (user override)\n * 2. OrbStack Docker with --network host: 127.0.0.1 (required for port forwarding)\n * 3. WSL/SSH/Remote: 0.0.0.0 (needed for cross-network access)\n * 4. Default: 127.0.0.1 (most secure for local development)\n */\nfunction getBindAddress(): string {\n  // Allow user override via environment variable\n  const envBind = process.env.OPENCODE_ANTIGRAVITY_OAUTH_BIND;\n  if (envBind) {\n    return envBind;\n  }\n  \n  // OrbStack Docker needs 127.0.0.1 for --network host port forwarding\n  if (isOrbStackDockerHost()) {\n    return \"127.0.0.1\";\n  }\n  \n  // WSL and remote environments need 0.0.0.0 to be reachable\n  if (isWSL() || isRemoteEnvironment()) {\n    return \"0.0.0.0\";\n  }\n  \n  // Default to 127.0.0.1 for security (local-only access)\n  return \"127.0.0.1\";\n}\n\n/**\n * Starts a lightweight HTTP server that listens for the Antigravity OAuth redirect\n * and resolves with the captured callback URL.\n */\nexport async function startOAuthListener(\n  { timeoutMs = 5 * 60 * 1000 }: OAuthListenerOptions = {},\n): Promise<OAuthListener> {\n  const port = redirectUri.port\n    ? Number.parseInt(redirectUri.port, 10)\n    : redirectUri.protocol === \"https:\"\n    ? 443\n    : 80;\n  const origin = `${redirectUri.protocol}//${redirectUri.host}`;\n\n  let settled = false;\n  let resolveCallback: (url: URL) => void;\n  let rejectCallback: (error: Error) => void;\n  let timeoutHandle: NodeJS.Timeout;\n  const callbackPromise = new Promise<URL>((resolve, reject) => {\n    resolveCallback = (url: URL) => {\n      if (settled) return;\n      settled = true;\n      if (timeoutHandle) clearTimeout(timeoutHandle);\n      resolve(url);\n    };\n    rejectCallback = (error: Error) => {\n      if (settled) return;\n      settled = true;\n      if (timeoutHandle) clearTimeout(timeoutHandle);\n      reject(error);\n    };\n  });\n\nconst successResponse = `<!DOCTYPE html>\n<html lang=\"en\">\n  <head>\n    <meta charset=\"utf-8\" />\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n    <title>Authentication Successful</title>\n    <style>\n      :root {\n        --bg: #FAFAFA;\n        --card-bg: #FFFFFF;\n        --text-primary: #1F2937;\n        --text-secondary: #6B7280;\n        --accent: #2563EB;\n        --success: #10B981;\n        --border: #E5E7EB;\n      }\n      @media (prefers-color-scheme: dark) {\n        :root {\n          --bg: #111827;\n          --card-bg: #1F2937;\n          --text-primary: #F9FAFB;\n          --text-secondary: #9CA3AF;\n          --accent: #3B82F6;\n          --success: #34D399;\n          --border: #374151;\n        }\n      }\n      body {\n        margin: 0;\n        min-height: 100vh;\n        display: flex;\n        align-items: center;\n        justify-content: center;\n        font-family: -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif;\n        background: var(--bg);\n        color: var(--text-primary);\n        padding: 1rem;\n      }\n      .card {\n        background: var(--card-bg);\n        border-radius: 16px;\n        padding: 3rem 2rem;\n        width: 100%;\n        max-width: 400px;\n        text-align: center;\n        box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);\n        border: 1px solid var(--border);\n      }\n      .icon-wrapper {\n        width: 64px;\n        height: 64px;\n        background: rgba(16, 185, 129, 0.1);\n        border-radius: 50%;\n        display: flex;\n        align-items: center;\n        justify-content: center;\n        margin: 0 auto 1.5rem;\n      }\n      .icon {\n        width: 32px;\n        height: 32px;\n        color: var(--success);\n      }\n      h1 {\n        font-size: 1.5rem;\n        font-weight: 600;\n        margin: 0 0 0.5rem;\n        letter-spacing: -0.025em;\n      }\n      p {\n        color: var(--text-secondary);\n        font-size: 0.95rem;\n        line-height: 1.5;\n        margin: 0 0 2rem;\n      }\n      .btn {\n        display: inline-flex;\n        align-items: center;\n        justify-content: center;\n        background: var(--text-primary);\n        color: var(--card-bg);\n        font-weight: 500;\n        padding: 0.75rem 1.5rem;\n        border-radius: 8px;\n        text-decoration: none;\n        transition: opacity 0.2s;\n        font-size: 0.95rem;\n        border: none;\n        cursor: pointer;\n        width: 100%;\n        box-sizing: border-box;\n      }\n      .btn:hover {\n        opacity: 0.9;\n      }\n      .sub-text {\n        margin-top: 1rem;\n        font-size: 0.8rem;\n        color: var(--text-secondary);\n      }\n    </style>\n  </head>\n  <body>\n    <div class=\"card\">\n      <div class=\"icon-wrapper\">\n        <svg class=\"icon\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n          <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2.5\" d=\"M5 13l4 4L19 7\" />\n        </svg>\n      </div>\n      <h1>All set!</h1>\n      <p>You've successfully authenticated with Antigravity. You can now return to Opencode.</p>\n      <button class=\"btn\" onclick=\"closeWindow()\">Close this tab</button>\n      <div class=\"sub-text\">Usage Tip: Most browsers block auto-closing. If the button doesn't work, please close the tab manually.</div>\n    </div>\n    <script>\n      function closeWindow() {\n        window.close();\n        // Fallback if window.close() is blocked\n        document.querySelector('.btn').textContent = \"Tab cannot be closed automatically\";\n        document.querySelector('.btn').style.opacity = \"0.5\";\n        document.querySelector('.btn').style.cursor = \"default\";\n      }\n    </script>\n  </body>\n</html>`;\n\n  timeoutHandle = setTimeout(() => {\n    rejectCallback(new Error(\"Timed out waiting for OAuth callback\"));\n  }, timeoutMs);\n  timeoutHandle.unref?.();\n\n  const server = createServer((request, response) => {\n    if (!request.url) {\n      response.writeHead(400, { \"Content-Type\": \"text/plain\" });\n      response.end(\"Invalid request\");\n      return;\n    }\n\n    const url = new URL(request.url, origin);\n    if (url.pathname !== callbackPath) {\n      response.writeHead(404, { \"Content-Type\": \"text/plain\" });\n      response.end(\"Not found\");\n      return;\n    }\n\n    response.writeHead(200, { \"Content-Type\": \"text/html; charset=utf-8\" });\n    response.end(successResponse);\n\n    resolveCallback(url);\n\n    setImmediate(() => {\n      server.close();\n    });\n  });\n\n  const bindAddress = getBindAddress();\n  \n  await new Promise<void>((resolve, reject) => {\n    const handleError = (error: NodeJS.ErrnoException) => {\n      server.off(\"error\", handleError);\n      if (error.code === \"EADDRINUSE\") {\n        reject(new Error(\n          `Port ${port} is already in use. ` +\n          `Another process is occupying this port. ` +\n          `Please terminate the process or try again later.`\n        ));\n        return;\n      }\n      reject(error);\n    };\n    server.once(\"error\", handleError);\n    server.listen(port, bindAddress, () => {\n      server.off(\"error\", handleError);\n      resolve();\n    });\n  });\n\n  server.on(\"error\", (error) => {\n    rejectCallback(error instanceof Error ? error : new Error(String(error)));\n  });\n\n  return {\n    waitForCallback: () => callbackPromise,\n    close: () =>\n      new Promise<void>((resolve, reject) => {\n        server.close((error) => {\n          if (error && (error as NodeJS.ErrnoException).code !== \"ERR_SERVER_NOT_RUNNING\") {\n            reject(error);\n            return;\n          }\n          if (!settled) {\n            rejectCallback(new Error(\"OAuth listener closed before callback\"));\n          }\n          resolve();\n        });\n      }),\n  };\n}\n"
  },
  {
    "path": "src/plugin/storage.test.ts",
    "content": "import { describe, expect, it, vi, beforeEach } from \"vitest\";\nimport {\n  deduplicateAccountsByEmail,\n  migrateV2ToV3,\n  loadAccounts,\n  type AccountMetadata,\n  type AccountStorage,\n} from \"./storage\";\nimport { promises as fs } from \"node:fs\";\nimport {\n  existsSync,\n  readFileSync,\n  writeFileSync,\n  appendFileSync,\n} from \"node:fs\";\n\nvi.mock(\"proper-lockfile\", () => ({\n  default: {\n    lock: vi.fn().mockResolvedValue(vi.fn().mockResolvedValue(undefined)),\n  },\n}));\n\ndescribe(\"deduplicateAccountsByEmail\", () => {\n  it(\"returns empty array for empty input\", () => {\n    const result = deduplicateAccountsByEmail([]);\n    expect(result).toEqual([]);\n  });\n\n  it(\"returns single account unchanged\", () => {\n    const accounts: AccountMetadata[] = [\n      {\n        email: \"test@example.com\",\n        refreshToken: \"r1\",\n        addedAt: 1000,\n        lastUsed: 2000,\n      },\n    ];\n    const result = deduplicateAccountsByEmail(accounts);\n    expect(result).toEqual(accounts);\n  });\n\n  it(\"keeps accounts without email (cannot deduplicate)\", () => {\n    const accounts: AccountMetadata[] = [\n      { refreshToken: \"r1\", addedAt: 1000, lastUsed: 2000 },\n      { refreshToken: \"r2\", addedAt: 1100, lastUsed: 2100 },\n    ];\n    const result = deduplicateAccountsByEmail(accounts);\n    expect(result).toHaveLength(2);\n    expect(result[0]?.refreshToken).toBe(\"r1\");\n    expect(result[1]?.refreshToken).toBe(\"r2\");\n  });\n\n  it(\"deduplicates accounts with same email, keeping newest by lastUsed\", () => {\n    const accounts: AccountMetadata[] = [\n      {\n        email: \"test@example.com\",\n        refreshToken: \"old-token\",\n        addedAt: 1000,\n        lastUsed: 1000,\n      },\n      {\n        email: \"test@example.com\",\n        refreshToken: \"new-token\",\n        addedAt: 2000,\n        lastUsed: 3000,\n      },\n    ];\n    const result = deduplicateAccountsByEmail(accounts);\n    expect(result).toHaveLength(1);\n    expect(result[0]?.refreshToken).toBe(\"new-token\");\n    expect(result[0]?.email).toBe(\"test@example.com\");\n  });\n\n  it(\"deduplicates accounts with same email, keeping newest by addedAt when lastUsed is equal\", () => {\n    const accounts: AccountMetadata[] = [\n      {\n        email: \"test@example.com\",\n        refreshToken: \"old-token\",\n        addedAt: 1000,\n        lastUsed: 0,\n      },\n      {\n        email: \"test@example.com\",\n        refreshToken: \"new-token\",\n        addedAt: 2000,\n        lastUsed: 0,\n      },\n    ];\n    const result = deduplicateAccountsByEmail(accounts);\n    expect(result).toHaveLength(1);\n    expect(result[0]?.refreshToken).toBe(\"new-token\");\n  });\n\n  it(\"handles multiple duplicate emails correctly\", () => {\n    const accounts: AccountMetadata[] = [\n      {\n        email: \"alice@example.com\",\n        refreshToken: \"alice-old\",\n        addedAt: 1000,\n        lastUsed: 1000,\n      },\n      {\n        email: \"bob@example.com\",\n        refreshToken: \"bob-old\",\n        addedAt: 1000,\n        lastUsed: 1000,\n      },\n      {\n        email: \"alice@example.com\",\n        refreshToken: \"alice-new\",\n        addedAt: 2000,\n        lastUsed: 3000,\n      },\n      {\n        email: \"bob@example.com\",\n        refreshToken: \"bob-new\",\n        addedAt: 2000,\n        lastUsed: 3000,\n      },\n      {\n        email: \"alice@example.com\",\n        refreshToken: \"alice-mid\",\n        addedAt: 1500,\n        lastUsed: 2000,\n      },\n    ];\n    const result = deduplicateAccountsByEmail(accounts);\n    expect(result).toHaveLength(2);\n\n    const alice = result.find((a) => a.email === \"alice@example.com\");\n    const bob = result.find((a) => a.email === \"bob@example.com\");\n\n    expect(alice?.refreshToken).toBe(\"alice-new\");\n    expect(bob?.refreshToken).toBe(\"bob-new\");\n  });\n\n  it(\"preserves order of kept accounts based on newest entry index\", () => {\n    const accounts: AccountMetadata[] = [\n      {\n        email: \"first@example.com\",\n        refreshToken: \"first-old\",\n        addedAt: 1000,\n        lastUsed: 1000,\n      },\n      {\n        email: \"second@example.com\",\n        refreshToken: \"second-new\",\n        addedAt: 3000,\n        lastUsed: 3000,\n      },\n      {\n        email: \"first@example.com\",\n        refreshToken: \"first-new\",\n        addedAt: 2000,\n        lastUsed: 2000,\n      },\n    ];\n    const result = deduplicateAccountsByEmail(accounts);\n    expect(result).toHaveLength(2);\n    // Kept entries are at indices 1 (second@) and 2 (first@), so order is second, first\n    expect(result[0]?.email).toBe(\"second@example.com\");\n    expect(result[1]?.email).toBe(\"first@example.com\");\n  });\n\n  it(\"mixes accounts with and without email correctly\", () => {\n    const accounts: AccountMetadata[] = [\n      {\n        email: \"test@example.com\",\n        refreshToken: \"r1\",\n        addedAt: 1000,\n        lastUsed: 1000,\n      },\n      { refreshToken: \"no-email-1\", addedAt: 1500, lastUsed: 1500 },\n      {\n        email: \"test@example.com\",\n        refreshToken: \"r2\",\n        addedAt: 2000,\n        lastUsed: 2000,\n      },\n      { refreshToken: \"no-email-2\", addedAt: 2500, lastUsed: 2500 },\n    ];\n    const result = deduplicateAccountsByEmail(accounts);\n    expect(result).toHaveLength(3);\n\n    // no-email-1 at index 1\n    // r2 (newest for test@example.com) at index 2\n    // no-email-2 at index 3\n    expect(result[0]?.refreshToken).toBe(\"no-email-1\");\n    expect(result[1]?.refreshToken).toBe(\"r2\");\n    expect(result[2]?.refreshToken).toBe(\"no-email-2\");\n  });\n\n  it(\"handles exact scenario from issue #24 (11 duplicate accounts)\", () => {\n    // Simulate user logging in 11 times with the same account\n    const accounts: AccountMetadata[] = [];\n    for (let i = 0; i < 11; i++) {\n      accounts.push({\n        email: \"user@example.com\",\n        refreshToken: `token-${i}`,\n        addedAt: 1000 + i * 100,\n        lastUsed: 1000 + i * 100,\n      });\n    }\n\n    const result = deduplicateAccountsByEmail(accounts);\n    expect(result).toHaveLength(1);\n    expect(result[0]?.refreshToken).toBe(\"token-10\"); // The newest one\n    expect(result[0]?.email).toBe(\"user@example.com\");\n  });\n});\n\nvi.mock(\"node:fs\", async () => {\n  const actual = await vi.importActual<typeof import(\"node:fs\")>(\"node:fs\");\n  return {\n    ...actual,\n    promises: {\n      ...actual.promises,\n      readFile: vi.fn(),\n      writeFile: vi.fn(),\n      mkdir: vi.fn().mockResolvedValue(undefined),\n      access: vi.fn().mockResolvedValue(undefined),\n      unlink: vi.fn(),\n      rename: vi.fn().mockResolvedValue(undefined),\n      appendFile: vi.fn(),\n    },\n    existsSync: vi.fn(),\n    readFileSync: vi.fn(),\n    writeFileSync: vi.fn(),\n    appendFileSync: vi.fn(),\n  };\n});\n\ndescribe(\"Storage Migration\", () => {\n  const now = Date.now();\n  const future = now + 100000;\n  const past = now - 100000;\n\n  describe(\"migrateV2ToV3\", () => {\n    it(\"converts gemini rate limits to gemini-antigravity\", () => {\n      const v2: AccountStorage = {\n        version: 2,\n        accounts: [\n          {\n            refreshToken: \"r1\",\n            addedAt: now,\n            lastUsed: now,\n            rateLimitResetTimes: {\n              gemini: future,\n            },\n          },\n        ],\n        activeIndex: 0,\n      };\n\n      const v3 = migrateV2ToV3(v2);\n\n      expect(v3.version).toBe(3);\n      const account = v3.accounts[0];\n      if (!account) throw new Error(\"Account not found\");\n\n      expect(account.rateLimitResetTimes).toEqual({\n        \"gemini-antigravity\": future,\n      });\n      expect(account.rateLimitResetTimes?.[\"gemini-cli\"]).toBeUndefined();\n    });\n\n    it(\"preserves claude rate limits\", () => {\n      const v2: AccountStorage = {\n        version: 2,\n        accounts: [\n          {\n            refreshToken: \"r1\",\n            addedAt: now,\n            lastUsed: now,\n            rateLimitResetTimes: {\n              claude: future,\n            },\n          },\n        ],\n        activeIndex: 0,\n      };\n\n      const v3 = migrateV2ToV3(v2);\n      const account = v3.accounts[0];\n      if (!account) throw new Error(\"Account not found\");\n\n      expect(account.rateLimitResetTimes).toEqual({\n        claude: future,\n      });\n    });\n\n    it(\"handles mixed rate limits correctly\", () => {\n      const v2: AccountStorage = {\n        version: 2,\n        accounts: [\n          {\n            refreshToken: \"r1\",\n            addedAt: now,\n            lastUsed: now,\n            rateLimitResetTimes: {\n              claude: future,\n              gemini: future,\n            },\n          },\n        ],\n        activeIndex: 0,\n      };\n\n      const v3 = migrateV2ToV3(v2);\n      const account = v3.accounts[0];\n      if (!account) throw new Error(\"Account not found\");\n\n      expect(account.rateLimitResetTimes).toEqual({\n        claude: future,\n        \"gemini-antigravity\": future,\n      });\n    });\n\n    it(\"filters out expired rate limits\", () => {\n      const v2: AccountStorage = {\n        version: 2,\n        accounts: [\n          {\n            refreshToken: \"r1\",\n            addedAt: now,\n            lastUsed: now,\n            rateLimitResetTimes: {\n              claude: past,\n              gemini: future,\n            },\n          },\n        ],\n        activeIndex: 0,\n      };\n\n      const v3 = migrateV2ToV3(v2);\n      const account = v3.accounts[0];\n      if (!account) throw new Error(\"Account not found\");\n\n      expect(account.rateLimitResetTimes).toEqual({\n        \"gemini-antigravity\": future,\n      });\n      expect(account.rateLimitResetTimes?.claude).toBeUndefined();\n    });\n\n    it(\"removes rateLimitResetTimes object if all keys are expired\", () => {\n      const v2: AccountStorage = {\n        version: 2,\n        accounts: [\n          {\n            refreshToken: \"r1\",\n            addedAt: now,\n            lastUsed: now,\n            rateLimitResetTimes: {\n              claude: past,\n              gemini: past,\n            },\n          },\n        ],\n        activeIndex: 0,\n      };\n\n      const v3 = migrateV2ToV3(v2);\n      const account = v3.accounts[0];\n      if (!account) throw new Error(\"Account not found\");\n\n      expect(account.rateLimitResetTimes).toBeUndefined();\n    });\n  });\n\n  describe(\"loadAccounts migration integration\", () => {\n    beforeEach(() => {\n      vi.clearAllMocks();\n    });\n\n    it(\"migrates V2 storage on load and persists V4\", async () => {\n      const v2Data = {\n        version: 2,\n        accounts: [\n          {\n            refreshToken: \"r1\",\n            addedAt: now,\n            lastUsed: now,\n            rateLimitResetTimes: {\n              gemini: future,\n            },\n          },\n        ],\n        activeIndex: 0,\n      };\n\n      // Mock readFile to return different values based on path\n      vi.mocked(fs.readFile).mockImplementation((path) => {\n        if ((path as string).endsWith(\".gitignore\")) {\n          const error = new Error(\"ENOENT\") as NodeJS.ErrnoException;\n          error.code = \"ENOENT\";\n          return Promise.reject(error);\n        }\n        return Promise.resolve(JSON.stringify(v2Data));\n      });\n\n      const result = await loadAccounts();\n\n      expect(result).not.toBeNull();\n      expect(result?.version).toBe(4);\n\n      const account = result?.accounts[0];\n      if (!account) throw new Error(\"Account not found\");\n\n      expect(account.rateLimitResetTimes).toEqual({\n        \"gemini-antigravity\": future,\n      });\n\n      expect(fs.writeFile).toHaveBeenCalled();\n      \n      const saveCall = vi.mocked(fs.writeFile).mock.calls.find(\n        (call) => (call[0] as string).includes(\".tmp\")\n      );\n      if (!saveCall) throw new Error(\"saveAccounts was not called (tmp file not found)\");\n\n      const savedContent = JSON.parse(saveCall[1] as string);\n      expect(savedContent.version).toBe(4);\n      expect(savedContent.accounts[0].rateLimitResetTimes).toEqual({\n        \"gemini-antigravity\": future,\n      });\n\n      const gitignoreCall = vi.mocked(fs.writeFile).mock.calls.find(\n        (call) => (call[0] as string).includes(\".gitignore\")\n      );\n      expect(gitignoreCall).toBeDefined();\n    });\n  });\n\n  describe(\"ensureGitignore\", () => {\n    const configDir = \"/tmp/opencode-test\";\n\n    beforeEach(() => {\n      vi.clearAllMocks();\n    });\n\n    it(\"creates .gitignore when file does not exist\", async () => {\n      vi.mocked(fs.readFile).mockRejectedValue({ code: \"ENOENT\" });\n\n      const { ensureGitignore } = await import(\"./storage\");\n      await ensureGitignore(configDir);\n\n      expect(fs.writeFile).toHaveBeenCalled();\n      const [path, content] = vi.mocked(fs.writeFile).mock.calls[0]!;\n      expect(path).toContain(\".gitignore\");\n      expect(content).toContain(\"antigravity-accounts.json\");\n      expect(content).toContain(\"antigravity-signature-cache.json\");\n      expect(content).toContain(\"antigravity-logs/\");\n    });\n\n    it(\"appends missing entries to existing .gitignore\", async () => {\n      vi.mocked(fs.readFile).mockResolvedValue(\"existing-entry\");\n\n      const { ensureGitignore } = await import(\"./storage\");\n      await ensureGitignore(configDir);\n\n      expect(fs.appendFile).toHaveBeenCalled();\n      const [path, content] = vi.mocked(fs.appendFile).mock.calls[0]!;\n      expect(path).toContain(\".gitignore\");\n      expect(content).toContain(\"antigravity-accounts.json\");\n      expect((content as string).startsWith(\"\\n\")).toBe(true);\n    });\n\n    it(\"does nothing when all entries already exist\", async () => {\n      const existing = [\n        \".gitignore\",\n        \"antigravity-accounts.json\",\n        \"antigravity-accounts.json.*.tmp\",\n        \"antigravity-signature-cache.json\",\n        \"antigravity-logs/\",\n      ].join(\"\\n\");\n      vi.mocked(fs.readFile).mockResolvedValue(existing);\n\n      const { ensureGitignore } = await import(\"./storage\");\n      await ensureGitignore(configDir);\n\n      expect(fs.writeFile).not.toHaveBeenCalled();\n      expect(fs.appendFile).not.toHaveBeenCalled();\n    });\n\n    it(\"handles permission errors gracefully\", async () => {\n      vi.mocked(fs.readFile).mockRejectedValue({ code: \"EACCES\" });\n\n      const { ensureGitignore } = await import(\"./storage\");\n      await expect(ensureGitignore(configDir)).resolves.not.toThrow();\n\n      expect(fs.writeFile).not.toHaveBeenCalled();\n      expect(fs.appendFile).not.toHaveBeenCalled();\n    });\n  });\n\n  describe(\"ensureGitignoreSync\", () => {\n    const configDir = \"/tmp/opencode-test-sync\";\n\n    beforeEach(() => {\n      vi.clearAllMocks();\n    });\n\n    it(\"creates .gitignore when file does not exist\", async () => {\n      vi.mocked(existsSync).mockReturnValue(false);\n\n      const { ensureGitignoreSync } = await import(\"./storage\");\n      ensureGitignoreSync(configDir);\n\n      expect(writeFileSync).toHaveBeenCalled();\n      const [path, content] = vi.mocked(writeFileSync).mock.calls[0]!;\n      expect(path).toContain(\".gitignore\");\n      expect(content).toContain(\"antigravity-accounts.json\");\n      expect(content).toContain(\"antigravity-signature-cache.json\");\n      expect(content).toContain(\"antigravity-logs/\");\n    });\n\n    it(\"appends missing entries to existing .gitignore\", async () => {\n      vi.mocked(existsSync).mockReturnValue(true);\n      vi.mocked(readFileSync).mockReturnValue(\"existing-entry\");\n\n      const { ensureGitignoreSync } = await import(\"./storage\");\n      ensureGitignoreSync(configDir);\n\n      expect(appendFileSync).toHaveBeenCalled();\n      const [path, content] = vi.mocked(appendFileSync).mock.calls[0]!;\n      expect(path).toContain(\".gitignore\");\n      expect(content).toContain(\"antigravity-accounts.json\");\n      expect((content as string).startsWith(\"\\n\")).toBe(true);\n    });\n\n    it(\"does nothing when all entries already exist\", async () => {\n      vi.mocked(existsSync).mockReturnValue(true);\n      const existing = [\n        \".gitignore\",\n        \"antigravity-accounts.json\",\n        \"antigravity-accounts.json.*.tmp\",\n        \"antigravity-signature-cache.json\",\n        \"antigravity-logs/\",\n      ].join(\"\\n\");\n      vi.mocked(readFileSync).mockReturnValue(existing);\n\n      const { ensureGitignoreSync } = await import(\"./storage\");\n      ensureGitignoreSync(configDir);\n\n      expect(writeFileSync).not.toHaveBeenCalled();\n      expect(appendFileSync).not.toHaveBeenCalled();\n    });\n  });\n});\n"
  },
  {
    "path": "src/plugin/storage.ts",
    "content": "import { promises as fs } from \"node:fs\";\nimport {\n  existsSync,\n  readFileSync,\n  writeFileSync,\n  appendFileSync,\n  mkdirSync,\n  renameSync,\n  copyFileSync,\n  unlinkSync,\n} from \"node:fs\";\nimport { dirname, join } from \"node:path\";\nimport { homedir } from \"node:os\";\nimport { randomBytes } from \"node:crypto\";\nimport lockfile from \"proper-lockfile\";\nimport type { HeaderStyle } from \"../constants\";\nimport { createLogger } from \"./logger\";\n\nconst log = createLogger(\"storage\");\n\n/**\n * Files/directories that should be gitignored in the config directory.\n * These contain sensitive data or machine-specific state.\n */\nexport const GITIGNORE_ENTRIES = [\n  \".gitignore\",\n  \"antigravity-accounts.json\",\n  \"antigravity-accounts.json.*.tmp\",\n  \"antigravity-signature-cache.json\",\n  \"antigravity-logs/\",\n];\n\n/**\n * Ensures a .gitignore file exists in the config directory with entries\n * for sensitive files. Creates the file if missing, or appends missing\n * entries if it already exists.\n */\nexport async function ensureGitignore(configDir: string): Promise<void> {\n  const gitignorePath = join(configDir, \".gitignore\");\n\n  try {\n    let content: string;\n    let existingLines: string[] = [];\n\n    try {\n      content = await fs.readFile(gitignorePath, \"utf-8\");\n      existingLines = content.split(\"\\n\").map((line) => line.trim());\n    } catch (error) {\n      if ((error as NodeJS.ErrnoException).code !== \"ENOENT\") {\n        return;\n      }\n      content = \"\";\n    }\n\n    const missingEntries = GITIGNORE_ENTRIES.filter(\n      (entry) => !existingLines.includes(entry),\n    );\n\n    if (missingEntries.length === 0) {\n      return;\n    }\n\n    if (content === \"\") {\n      await fs.writeFile(\n        gitignorePath,\n        missingEntries.join(\"\\n\") + \"\\n\",\n        \"utf-8\",\n      );\n      log.info(\"Created .gitignore in config directory\");\n    } else {\n      const suffix = content.endsWith(\"\\n\") ? \"\" : \"\\n\";\n      await fs.appendFile(\n        gitignorePath,\n        suffix + missingEntries.join(\"\\n\") + \"\\n\",\n        \"utf-8\",\n      );\n      log.info(\"Updated .gitignore with missing entries\", {\n        added: missingEntries,\n      });\n    }\n  } catch {\n    // Non-critical feature\n  }\n}\n\n/**\n * Synchronous version of ensureGitignore for use in sync code paths.\n */\nexport function ensureGitignoreSync(configDir: string): void {\n  const gitignorePath = join(configDir, \".gitignore\");\n\n  try {\n    let content: string;\n    let existingLines: string[] = [];\n\n    if (existsSync(gitignorePath)) {\n      content = readFileSync(gitignorePath, \"utf-8\");\n      existingLines = content.split(\"\\n\").map((line) => line.trim());\n    } else {\n      content = \"\";\n    }\n\n    const missingEntries = GITIGNORE_ENTRIES.filter(\n      (entry) => !existingLines.includes(entry),\n    );\n\n    if (missingEntries.length === 0) {\n      return;\n    }\n\n    if (content === \"\") {\n      writeFileSync(gitignorePath, missingEntries.join(\"\\n\") + \"\\n\", \"utf-8\");\n      log.info(\"Created .gitignore in config directory\");\n    } else {\n      const suffix = content.endsWith(\"\\n\") ? \"\" : \"\\n\";\n      appendFileSync(\n        gitignorePath,\n        suffix + missingEntries.join(\"\\n\") + \"\\n\",\n        \"utf-8\",\n      );\n      log.info(\"Updated .gitignore with missing entries\", {\n        added: missingEntries,\n      });\n    }\n  } catch {\n    // Non-critical feature\n  }\n}\n\nexport type ModelFamily = \"claude\" | \"gemini\";\nexport type { HeaderStyle };\n\nexport interface RateLimitState {\n  claude?: number;\n  gemini?: number;\n}\n\nexport interface RateLimitStateV3 {\n  claude?: number;\n  \"gemini-antigravity\"?: number;\n  \"gemini-cli\"?: number;\n  [key: string]: number | undefined;\n}\n\nexport interface AccountMetadataV1 {\n  email?: string;\n  refreshToken: string;\n  projectId?: string;\n  managedProjectId?: string;\n  addedAt: number;\n  lastUsed: number;\n  isRateLimited?: boolean;\n  rateLimitResetTime?: number;\n  lastSwitchReason?: \"rate-limit\" | \"initial\" | \"rotation\";\n}\n\nexport interface AccountStorageV1 {\n  version: 1;\n  accounts: AccountMetadataV1[];\n  activeIndex: number;\n}\n\nexport interface AccountMetadata {\n  email?: string;\n  refreshToken: string;\n  projectId?: string;\n  managedProjectId?: string;\n  addedAt: number;\n  lastUsed: number;\n  lastSwitchReason?: \"rate-limit\" | \"initial\" | \"rotation\";\n  rateLimitResetTimes?: RateLimitState;\n}\n\nexport interface AccountStorage {\n  version: 2;\n  accounts: AccountMetadata[];\n  activeIndex: number;\n}\n\nexport type CooldownReason = \"auth-failure\" | \"network-error\" | \"project-error\" | \"validation-required\";\n\nexport interface AccountMetadataV3 {\n  email?: string;\n  refreshToken: string;\n  projectId?: string;\n  managedProjectId?: string;\n  addedAt: number;\n  lastUsed: number;\n  enabled?: boolean;\n  lastSwitchReason?: \"rate-limit\" | \"initial\" | \"rotation\";\n  rateLimitResetTimes?: RateLimitStateV3;\n  coolingDownUntil?: number;\n  cooldownReason?: CooldownReason;\n  /** Per-account device fingerprint for rate limit mitigation */\n  fingerprint?: import(\"./fingerprint\").Fingerprint;\n  fingerprintHistory?: import(\"./fingerprint\").FingerprintVersion[];\n  /** Set when Google asks the user to verify this account before requests can continue. */\n  verificationRequired?: boolean;\n  verificationRequiredAt?: number;\n  verificationRequiredReason?: string;\n  verificationUrl?: string;\n  /** Cached soft quota data */\n  cachedQuota?: Record<string, { remainingFraction?: number; resetTime?: string; modelCount: number }>;\n  cachedQuotaUpdatedAt?: number;\n}\n\nexport interface AccountStorageV3 {\n  version: 3;\n  accounts: AccountMetadataV3[];\n  activeIndex: number;\n  activeIndexByFamily?: {\n    claude?: number;\n    gemini?: number;\n  };\n}\n\nexport interface AccountStorageV4 {\n  version: 4;\n  accounts: AccountMetadataV3[];\n  activeIndex: number;\n  activeIndexByFamily?: {\n    claude?: number;\n    gemini?: number;\n  };\n}\n\ntype AnyAccountStorage =\n  | AccountStorageV1\n  | AccountStorage\n  | AccountStorageV3\n  | AccountStorageV4;\n\n/**\n * Gets the legacy Windows config directory (%APPDATA%\\opencode).\n * Used for migration from older plugin versions.\n */\nfunction getLegacyWindowsConfigDir(): string {\n  return join(\n    process.env.APPDATA || join(homedir(), \"AppData\", \"Roaming\"),\n    \"opencode\",\n  );\n}\n\n/**\n * Gets the config directory path, with the following precedence:\n * 1. OPENCODE_CONFIG_DIR env var (if set)\n * 2. ~/.config/opencode (all platforms, including Windows)\n *\n * On Windows, also checks for legacy %APPDATA%\\opencode path for migration.\n */\nfunction getConfigDir(): string {\n  // 1. Check for explicit override via env var\n  if (process.env.OPENCODE_CONFIG_DIR) {\n    return process.env.OPENCODE_CONFIG_DIR;\n  }\n\n  // 2. Use ~/.config/opencode on all platforms (including Windows)\n  const xdgConfig = process.env.XDG_CONFIG_HOME || join(homedir(), \".config\");\n  return join(xdgConfig, \"opencode\");\n}\n\n/**\n * Migrates config from legacy Windows location to the new path.\n * Moves the file if legacy exists and new doesn't.\n * Returns true if migration was performed.\n */\nfunction migrateLegacyWindowsConfig(): boolean {\n  if (process.platform !== \"win32\") {\n    return false;\n  }\n\n  const newPath = join(getConfigDir(), \"antigravity-accounts.json\");\n  const legacyPath = join(\n    getLegacyWindowsConfigDir(),\n    \"antigravity-accounts.json\",\n  );\n\n  // Only migrate if legacy exists and new doesn't\n  if (!existsSync(legacyPath) || existsSync(newPath)) {\n    return false;\n  }\n\n  try {\n    // Ensure new config directory exists\n    const newConfigDir = getConfigDir();\n\n    mkdirSync(newConfigDir, { recursive: true });\n\n    // Try rename first (atomic, but fails across filesystems)\n    try {\n      renameSync(legacyPath, newPath);\n      log.info(\"Migrated Windows config via rename\", { from: legacyPath, to: newPath });\n    } catch {\n      // Fallback: copy then delete (for cross-filesystem moves)\n      copyFileSync(legacyPath, newPath);\n      unlinkSync(legacyPath);\n      log.info(\"Migrated Windows config via copy+delete\", { from: legacyPath, to: newPath });\n    }\n\n    return true;\n  } catch (error) {\n    log.warn(\"Failed to migrate legacy Windows config, will use legacy path\", {\n      legacyPath,\n      newPath,\n      error: String(error),\n    });\n    return false;\n  }\n}\n\n/**\n * Gets the storage path, migrating from legacy Windows location if needed.\n * On Windows, attempts to move legacy config to new path for alignment.\n */\nfunction getStoragePathWithMigration(): string {\n  const newPath = join(getConfigDir(), \"antigravity-accounts.json\");\n\n  // On Windows, attempt to migrate legacy config to new location\n  if (process.platform === \"win32\") {\n    migrateLegacyWindowsConfig();\n\n    // If migration failed and legacy still exists, fall back to it\n    if (!existsSync(newPath)) {\n      const legacyPath = join(\n        getLegacyWindowsConfigDir(),\n        \"antigravity-accounts.json\",\n      );\n      if (existsSync(legacyPath)) {\n        log.info(\"Using legacy Windows config path (migration failed)\", {\n          legacyPath,\n          newPath,\n        });\n        return legacyPath;\n      }\n    }\n  }\n\n  return newPath;\n}\n\nexport function getStoragePath(): string {\n  return getStoragePathWithMigration();\n}\n\n/**\n * Gets the config directory path. Exported for use by other modules.\n */\nexport { getConfigDir };\n\nconst LOCK_OPTIONS = {\n  stale: 10000,\n  retries: {\n    retries: 5,\n    minTimeout: 100,\n    maxTimeout: 1000,\n    factor: 2,\n  },\n};\n\n/**\n * Ensures the file has secure permissions (0600) on POSIX systems.\n * This is a best-effort operation and ignores errors on Windows/unsupported FS.\n */\nasync function ensureSecurePermissions(path: string): Promise<void> {\n  try {\n    await fs.chmod(path, 0o600);\n  } catch {\n    // Ignore errors (e.g. Windows, file doesn't exist, FS doesn't support chmod)\n  }\n}\n\nasync function ensureFileExists(path: string): Promise<void> {\n  try {\n    await fs.access(path);\n  } catch {\n    await fs.mkdir(dirname(path), { recursive: true });\n    await fs.writeFile(\n      path,\n      JSON.stringify({ version: 4, accounts: [], activeIndex: 0 }, null, 2),\n      { encoding: \"utf-8\", mode: 0o600 },\n    );\n  }\n}\n\nasync function withFileLock<T>(path: string, fn: () => Promise<T>): Promise<T> {\n  await ensureFileExists(path);\n  let release: (() => Promise<void>) | null = null;\n  try {\n    release = await lockfile.lock(path, LOCK_OPTIONS);\n    return await fn();\n  } finally {\n    if (release) {\n      try {\n        await release();\n      } catch (unlockError) {\n        log.warn(\"Failed to release lock\", { error: String(unlockError) });\n      }\n    }\n  }\n}\n\nfunction mergeAccountStorage(\n  existing: AccountStorageV4,\n  incoming: AccountStorageV4,\n): AccountStorageV4 {\n  const accountMap = new Map<string, AccountMetadataV3>();\n\n  for (const acc of existing.accounts) {\n    if (acc.refreshToken) {\n      accountMap.set(acc.refreshToken, acc);\n    }\n  }\n\n  for (const acc of incoming.accounts) {\n    if (acc.refreshToken) {\n      const existingAcc = accountMap.get(acc.refreshToken);\n      if (existingAcc) {\n        accountMap.set(acc.refreshToken, {\n          ...existingAcc,\n          ...acc,\n          // Preserve manually configured projectId/managedProjectId if not in incoming\n          projectId: acc.projectId ?? existingAcc.projectId,\n          managedProjectId: acc.managedProjectId ?? existingAcc.managedProjectId,\n          rateLimitResetTimes: {\n            ...existingAcc.rateLimitResetTimes,\n            ...acc.rateLimitResetTimes,\n          },\n          lastUsed: Math.max(existingAcc.lastUsed || 0, acc.lastUsed || 0),\n        });\n      } else {\n        accountMap.set(acc.refreshToken, acc);\n      }\n    }\n  }\n\n  return {\n    version: 4,\n    accounts: Array.from(accountMap.values()),\n    activeIndex: incoming.activeIndex,\n    activeIndexByFamily: incoming.activeIndexByFamily,\n  };\n}\n\nexport function deduplicateAccountsByEmail<\n  T extends { email?: string; lastUsed?: number; addedAt?: number },\n>(accounts: T[]): T[] {\n  const emailToNewestIndex = new Map<string, number>();\n  const indicesToKeep = new Set<number>();\n\n  // First pass: find the newest account for each email (by lastUsed, then addedAt)\n  for (let i = 0; i < accounts.length; i++) {\n    const acc = accounts[i];\n    if (!acc) continue;\n\n    if (!acc.email) {\n      // No email - keep this account (can't deduplicate without email)\n      indicesToKeep.add(i);\n      continue;\n    }\n\n    const existingIndex = emailToNewestIndex.get(acc.email);\n    if (existingIndex === undefined) {\n      emailToNewestIndex.set(acc.email, i);\n      continue;\n    }\n\n    // Compare to find which is newer\n    const existing = accounts[existingIndex];\n    if (!existing) {\n      emailToNewestIndex.set(acc.email, i);\n      continue;\n    }\n\n    // Prefer higher lastUsed, then higher addedAt\n    // Compare fields separately to avoid integer overflow with large timestamps\n    const currLastUsed = acc.lastUsed || 0;\n    const existLastUsed = existing.lastUsed || 0;\n    const currAddedAt = acc.addedAt || 0;\n    const existAddedAt = existing.addedAt || 0;\n\n    const isNewer =\n      currLastUsed > existLastUsed ||\n      (currLastUsed === existLastUsed && currAddedAt > existAddedAt);\n\n    if (isNewer) {\n      emailToNewestIndex.set(acc.email, i);\n    }\n  }\n\n  // Add all the newest email-based indices to the keep set\n  for (const idx of emailToNewestIndex.values()) {\n    indicesToKeep.add(idx);\n  }\n\n  // Build the deduplicated list, preserving original order for kept items\n  const result: T[] = [];\n  for (let i = 0; i < accounts.length; i++) {\n    if (indicesToKeep.has(i)) {\n      const acc = accounts[i];\n      if (acc) {\n        result.push(acc);\n      }\n    }\n  }\n\n  return result;\n}\n\nfunction migrateV1ToV2(v1: AccountStorageV1): AccountStorage {\n  return {\n    version: 2,\n    accounts: v1.accounts.map((acc) => {\n      const rateLimitResetTimes: RateLimitState = {};\n      if (\n        acc.isRateLimited &&\n        acc.rateLimitResetTime &&\n        acc.rateLimitResetTime > Date.now()\n      ) {\n        rateLimitResetTimes.claude = acc.rateLimitResetTime;\n        rateLimitResetTimes.gemini = acc.rateLimitResetTime;\n      }\n      return {\n        email: acc.email,\n        refreshToken: acc.refreshToken,\n        projectId: acc.projectId,\n        managedProjectId: acc.managedProjectId,\n        addedAt: acc.addedAt,\n        lastUsed: acc.lastUsed,\n        lastSwitchReason: acc.lastSwitchReason,\n        rateLimitResetTimes:\n          Object.keys(rateLimitResetTimes).length > 0\n            ? rateLimitResetTimes\n            : undefined,\n      };\n    }),\n    activeIndex: v1.activeIndex,\n  };\n}\n\nexport function migrateV2ToV3(v2: AccountStorage): AccountStorageV3 {\n  return {\n    version: 3,\n    accounts: v2.accounts.map((acc) => {\n      const rateLimitResetTimes: RateLimitStateV3 = {};\n      if (\n        acc.rateLimitResetTimes?.claude &&\n        acc.rateLimitResetTimes.claude > Date.now()\n      ) {\n        rateLimitResetTimes.claude = acc.rateLimitResetTimes.claude;\n      }\n      if (\n        acc.rateLimitResetTimes?.gemini &&\n        acc.rateLimitResetTimes.gemini > Date.now()\n      ) {\n        rateLimitResetTimes[\"gemini-antigravity\"] =\n          acc.rateLimitResetTimes.gemini;\n      }\n      return {\n        email: acc.email,\n        refreshToken: acc.refreshToken,\n        projectId: acc.projectId,\n        managedProjectId: acc.managedProjectId,\n        addedAt: acc.addedAt,\n        lastUsed: acc.lastUsed,\n        lastSwitchReason: acc.lastSwitchReason,\n        rateLimitResetTimes:\n          Object.keys(rateLimitResetTimes).length > 0\n            ? rateLimitResetTimes\n            : undefined,\n      };\n    }),\n    activeIndex: v2.activeIndex,\n  };\n}\n\nexport function migrateV3ToV4(v3: AccountStorageV3): AccountStorageV4 {\n  return {\n    version: 4,\n    accounts: v3.accounts.map((acc) => ({\n      ...acc,\n      fingerprint: undefined,\n      fingerprintHistory: undefined,\n    })),\n    activeIndex: v3.activeIndex,\n    activeIndexByFamily: v3.activeIndexByFamily,\n  };\n}\n\nexport async function loadAccounts(): Promise<AccountStorageV4 | null> {\n  try {\n    const path = getStoragePath();\n    // Ensure permissions are correct on load (fixes existing files)\n    await ensureSecurePermissions(path);\n\n    const content = await fs.readFile(path, \"utf-8\");\n    const data = JSON.parse(content) as AnyAccountStorage;\n\n    if (!Array.isArray(data.accounts)) {\n      log.warn(\"Invalid storage format, ignoring\");\n      return null;\n    }\n\n    let storage: AccountStorageV4;\n\n    if (data.version === 1) {\n      log.info(\"Migrating account storage from v1 to v4\");\n      const v2 = migrateV1ToV2(data);\n      const v3 = migrateV2ToV3(v2);\n      storage = migrateV3ToV4(v3);\n      try {\n        await saveAccounts(storage);\n        log.info(\"Migration to v4 complete\");\n      } catch (saveError) {\n        log.warn(\"Failed to persist migrated storage\", {\n          error: String(saveError),\n        });\n      }\n    } else if (data.version === 2) {\n      log.info(\"Migrating account storage from v2 to v4\");\n      const v3 = migrateV2ToV3(data);\n      storage = migrateV3ToV4(v3);\n      try {\n        await saveAccounts(storage);\n        log.info(\"Migration to v4 complete\");\n      } catch (saveError) {\n        log.warn(\"Failed to persist migrated storage\", {\n          error: String(saveError),\n        });\n      }\n    } else if (data.version === 3) {\n      log.info(\"Migrating account storage from v3 to v4\");\n      storage = migrateV3ToV4(data);\n      try {\n        await saveAccounts(storage);\n        log.info(\"Migration to v4 complete\");\n      } catch (saveError) {\n        log.warn(\"Failed to persist migrated storage\", {\n          error: String(saveError),\n        });\n      }\n    } else if (data.version === 4) {\n      storage = data;\n    } else {\n      log.warn(\"Unknown storage version, ignoring\", {\n        version: (data as { version?: unknown }).version,\n      });\n      return null;\n    }\n\n    // Validate accounts have required fields\n    const validAccounts = storage.accounts.filter(\n      (a): a is AccountMetadataV3 => {\n        return (\n          !!a &&\n          typeof a === \"object\" &&\n          typeof (a as AccountMetadataV3).refreshToken === \"string\"\n        );\n      },\n    );\n\n    // Deduplicate accounts by email (keeps newest entry for each email)\n    const deduplicatedAccounts = deduplicateAccountsByEmail(validAccounts);\n\n    // Clamp activeIndex to valid range after deduplication\n    let activeIndex =\n      typeof storage.activeIndex === \"number\" &&\n      Number.isFinite(storage.activeIndex)\n        ? storage.activeIndex\n        : 0;\n    if (deduplicatedAccounts.length > 0) {\n      activeIndex = Math.min(activeIndex, deduplicatedAccounts.length - 1);\n      activeIndex = Math.max(activeIndex, 0);\n    } else {\n      activeIndex = 0;\n    }\n\n    return {\n      version: 4,\n      accounts: deduplicatedAccounts,\n      activeIndex,\n      activeIndexByFamily: storage.activeIndexByFamily,\n    };\n  } catch (error) {\n    const code = (error as NodeJS.ErrnoException).code;\n    if (code === \"ENOENT\") {\n      return null;\n    }\n    log.error(\"Failed to load account storage\", { error: String(error) });\n    return null;\n  }\n}\n\nexport async function saveAccounts(storage: AccountStorageV4): Promise<void> {\n  const path = getStoragePath();\n  const configDir = dirname(path);\n  await fs.mkdir(configDir, { recursive: true });\n  await ensureGitignore(configDir);\n\n  await withFileLock(path, async () => {\n    const existing = await loadAccountsUnsafe();\n    const merged = existing ? mergeAccountStorage(existing, storage) : storage;\n\n    const tempPath = `${path}.${randomBytes(6).toString(\"hex\")}.tmp`;\n    const content = JSON.stringify(merged, null, 2);\n\n    try {\n      await fs.writeFile(tempPath, content, { encoding: \"utf-8\", mode: 0o600 });\n      await fs.rename(tempPath, path);\n    } catch (error) {\n      // Clean up temp file on failure to prevent accumulation\n      try {\n        await fs.unlink(tempPath);\n      } catch {\n        // Ignore cleanup errors (file may not exist)\n      }\n      throw error;\n    }\n  });\n}\n\n/**\n * Save accounts storage by replacing the entire file (no merge).\n * Use this for destructive operations like delete where we need to\n * remove accounts that would otherwise be merged back from existing storage.\n */\nexport async function saveAccountsReplace(storage: AccountStorageV4): Promise<void> {\n  const path = getStoragePath();\n  const configDir = dirname(path);\n  await fs.mkdir(configDir, { recursive: true });\n  await ensureGitignore(configDir);\n\n  await withFileLock(path, async () => {\n    const tempPath = `${path}.${randomBytes(6).toString(\"hex\")}.tmp`;\n    const content = JSON.stringify(storage, null, 2);\n\n    try {\n      await fs.writeFile(tempPath, content, { encoding: \"utf-8\", mode: 0o600 });\n      await fs.rename(tempPath, path);\n    } catch (error) {\n      try {\n        await fs.unlink(tempPath);\n      } catch {\n        // Ignore cleanup errors\n      }\n      throw error;\n    }\n  });\n}\n\nasync function loadAccountsUnsafe(): Promise<AccountStorageV4 | null> {\n  try {\n    const path = getStoragePath();\n    // Ensure permissions are correct on load (fixes existing files)\n    await ensureSecurePermissions(path);\n\n    const content = await fs.readFile(path, \"utf-8\");\n    const parsed = JSON.parse(content);\n\n    if (parsed.version === 1) {\n      return migrateV3ToV4(migrateV2ToV3(migrateV1ToV2(parsed)));\n    }\n    if (parsed.version === 2) {\n      return migrateV3ToV4(migrateV2ToV3(parsed));\n    }\n    if (parsed.version === 3) {\n      return migrateV3ToV4(parsed);\n    }\n\n    return {\n      ...parsed,\n      accounts: deduplicateAccountsByEmail(parsed.accounts),\n    };\n  } catch (error) {\n    const code = (error as NodeJS.ErrnoException).code;\n    if (code === \"ENOENT\") {\n      return null;\n    }\n    return null;\n  }\n}\n\nexport async function clearAccounts(): Promise<void> {\n  try {\n    const path = getStoragePath();\n    await fs.unlink(path);\n  } catch (error) {\n    const code = (error as NodeJS.ErrnoException).code;\n    if (code !== \"ENOENT\") {\n      log.error(\"Failed to clear account storage\", { error: String(error) });\n    }\n  }\n}\n"
  },
  {
    "path": "src/plugin/stores/signature-store.ts",
    "content": "import type { SignatureStore, SignedThinking, ThoughtBuffer } from '../core/streaming/types';\n\nexport function createSignatureStore(): SignatureStore {\n  const store = new Map<string, SignedThinking>();\n\n  return {\n    get: (key: string) => store.get(key),\n    set: (key: string, value: SignedThinking) => {\n      store.set(key, value);\n    },\n    has: (key: string) => store.has(key),\n    delete: (key: string) => {\n      store.delete(key);\n    },\n  };\n}\n\nexport function createThoughtBuffer(): ThoughtBuffer {\n  const buffer = new Map<number, string>();\n\n  return {\n    get: (index: number) => buffer.get(index),\n    set: (index: number, text: string) => {\n      buffer.set(index, text);\n    },\n    clear: () => buffer.clear(),\n  };\n}\n\nexport const defaultSignatureStore = createSignatureStore();\n"
  },
  {
    "path": "src/plugin/thinking-recovery.ts",
    "content": "/**\n * Thinking Recovery Module\n *\n * Minimal implementation for recovering from corrupted thinking state.\n * When Claude's conversation history gets corrupted (thinking blocks stripped/malformed),\n * this module provides a \"last resort\" recovery by closing the current turn and starting fresh.\n *\n * Philosophy: \"Let it crash and start again\" - Instead of trying to fix corrupted state,\n * we abandon the corrupted turn and let Claude generate fresh thinking.\n */\n\n// ============================================================================\n// TYPES\n// ============================================================================\n\n/**\n * Conversation state for thinking mode analysis\n */\nexport interface ConversationState {\n  /** True if we're in an incomplete tool use loop (ends with functionResponse) */\n  inToolLoop: boolean;\n  /** Index of first model message in current turn */\n  turnStartIdx: number;\n  /** Whether the TURN started with thinking */\n  turnHasThinking: boolean;\n  /** Index of last model message */\n  lastModelIdx: number;\n  /** Whether last model msg has thinking */\n  lastModelHasThinking: boolean;\n  /** Whether last model msg has tool calls */\n  lastModelHasToolCalls: boolean;\n}\n\n// ============================================================================\n// DETECTION HELPERS\n// ============================================================================\n\n/**\n * Checks if a message part is a thinking/reasoning block.\n */\nfunction isThinkingPart(part: any): boolean {\n  if (!part || typeof part !== \"object\") return false;\n  return (\n    part.thought === true ||\n    part.type === \"thinking\" ||\n    part.type === \"redacted_thinking\"\n  );\n}\n\n/**\n * Checks if a message part is a function response (tool result).\n */\nfunction isFunctionResponsePart(part: any): boolean {\n  return part && typeof part === \"object\" && \"functionResponse\" in part;\n}\n\n/**\n * Checks if a message part is a function call.\n */\nfunction isFunctionCallPart(part: any): boolean {\n  return part && typeof part === \"object\" && \"functionCall\" in part;\n}\n\n/**\n * Checks if a message is a tool result container (user role with functionResponse).\n */\nfunction isToolResultMessage(msg: any): boolean {\n  if (!msg || msg.role !== \"user\") return false;\n  const parts = msg.parts || [];\n  return parts.some(isFunctionResponsePart);\n}\n\n/**\n * Checks if a message contains thinking/reasoning content.\n */\nfunction messageHasThinking(msg: any): boolean {\n  if (!msg || typeof msg !== \"object\") return false;\n\n  // Gemini format: parts array\n  if (Array.isArray(msg.parts)) {\n    return msg.parts.some(isThinkingPart);\n  }\n\n  // Anthropic format: content array\n  if (Array.isArray(msg.content)) {\n    return msg.content.some(\n      (block: any) =>\n        block?.type === \"thinking\" || block?.type === \"redacted_thinking\",\n    );\n  }\n\n  return false;\n}\n\n/**\n * Checks if a message contains tool calls.\n */\nfunction messageHasToolCalls(msg: any): boolean {\n  if (!msg || typeof msg !== \"object\") return false;\n\n  // Gemini format: parts array with functionCall\n  if (Array.isArray(msg.parts)) {\n    return msg.parts.some(isFunctionCallPart);\n  }\n\n  // Anthropic format: content array with tool_use\n  if (Array.isArray(msg.content)) {\n    return msg.content.some((block: any) => block?.type === \"tool_use\");\n  }\n\n  return false;\n}\n\n// ============================================================================\n// CONVERSATION STATE ANALYSIS\n// ============================================================================\n\n/**\n * Analyzes conversation state to detect tool use loops and thinking mode issues.\n *\n * Key insight: A \"turn\" can span multiple assistant messages in a tool-use loop.\n * We need to find the TURN START (first assistant message after last real user message)\n * and check if THAT message had thinking, not just the last assistant message.\n */\nexport function analyzeConversationState(contents: any[]): ConversationState {\n  const state: ConversationState = {\n    inToolLoop: false,\n    turnStartIdx: -1,\n    turnHasThinking: false,\n    lastModelIdx: -1,\n    lastModelHasThinking: false,\n    lastModelHasToolCalls: false,\n  };\n\n  if (!Array.isArray(contents) || contents.length === 0) {\n    return state;\n  }\n\n  // First pass: Find the last \"real\" user message (not a tool result)\n  let lastRealUserIdx = -1;\n  for (let i = 0; i < contents.length; i++) {\n    const msg = contents[i];\n    if (msg?.role === \"user\" && !isToolResultMessage(msg)) {\n      lastRealUserIdx = i;\n    }\n  }\n\n  // Second pass: Analyze conversation and find turn boundaries\n  for (let i = 0; i < contents.length; i++) {\n    const msg = contents[i];\n    const role = msg?.role;\n\n    if (role === \"model\" || role === \"assistant\") {\n      const hasThinking = messageHasThinking(msg);\n      const hasToolCalls = messageHasToolCalls(msg);\n\n      // Track if this is the turn start\n      if (i > lastRealUserIdx && state.turnStartIdx === -1) {\n        state.turnStartIdx = i;\n        state.turnHasThinking = hasThinking;\n      }\n\n      state.lastModelIdx = i;\n      state.lastModelHasToolCalls = hasToolCalls;\n      state.lastModelHasThinking = hasThinking;\n    }\n  }\n\n  // Determine if we're in a tool loop\n  // We're in a tool loop if the conversation ends with a tool result\n  if (contents.length > 0) {\n    const lastMsg = contents[contents.length - 1];\n    if (lastMsg?.role === \"user\" && isToolResultMessage(lastMsg)) {\n      state.inToolLoop = true;\n    }\n  }\n\n  return state;\n}\n\n// ============================================================================\n// RECOVERY FUNCTIONS\n// ============================================================================\n\n/**\n * Strips all thinking blocks from messages.\n * Used before injecting synthetic messages to avoid invalid thinking patterns.\n */\nfunction stripAllThinkingBlocks(contents: any[]): any[] {\n  return contents.map((content) => {\n    if (!content || typeof content !== \"object\") return content;\n\n    // Handle Gemini-style parts\n    if (Array.isArray(content.parts)) {\n      const filteredParts = content.parts.filter(\n        (part: any) => !isThinkingPart(part),\n      );\n      // Keep at least one part to avoid empty messages\n      if (filteredParts.length === 0 && content.parts.length > 0) {\n        return content;\n      }\n      return { ...content, parts: filteredParts };\n    }\n\n    // Handle Anthropic-style content\n    if (Array.isArray(content.content)) {\n      const filteredContent = content.content.filter(\n        (block: any) =>\n          block?.type !== \"thinking\" && block?.type !== \"redacted_thinking\",\n      );\n      if (filteredContent.length === 0 && content.content.length > 0) {\n        return content;\n      }\n      return { ...content, content: filteredContent };\n    }\n\n    return content;\n  });\n}\n\n/**\n * Counts tool results at the end of the conversation.\n */\nfunction countTrailingToolResults(contents: any[]): number {\n  let count = 0;\n\n  for (let i = contents.length - 1; i >= 0; i--) {\n    const msg = contents[i];\n\n    if (msg?.role === \"user\") {\n      const parts = msg.parts || [];\n      const functionResponses = parts.filter(isFunctionResponsePart);\n\n      if (functionResponses.length > 0) {\n        count += functionResponses.length;\n      } else {\n        break; // Real user message, stop counting\n      }\n    } else if (msg?.role === \"model\" || msg?.role === \"assistant\") {\n      break; // Stop at the model that made the tool calls\n    }\n  }\n\n  return count;\n}\n\n/**\n * Closes an incomplete tool loop by injecting synthetic messages to start a new turn.\n *\n * This is the \"let it crash and start again\" recovery mechanism.\n *\n * When we detect:\n * - We're in a tool loop (conversation ends with functionResponse)\n * - The tool call was made WITHOUT thinking (thinking was stripped/corrupted)\n * - We NOW want to enable thinking\n *\n * Instead of trying to fix the corrupted state, we:\n * 1. Strip ALL thinking blocks (removes any corrupted ones)\n * 2. Add synthetic MODEL message to complete the non-thinking turn\n * 3. Add synthetic USER message to start a NEW turn\n *\n * This allows Claude to generate fresh thinking for the new turn.\n */\nexport function closeToolLoopForThinking(contents: any[]): any[] {\n  // Strip any old/corrupted thinking first\n  const strippedContents = stripAllThinkingBlocks(contents);\n\n  // Count tool results from the end of the conversation\n  const toolResultCount = countTrailingToolResults(strippedContents);\n\n  // Build synthetic model message content based on tool count\n  let syntheticModelContent: string;\n  if (toolResultCount === 0) {\n    syntheticModelContent = \"[Processing previous context.]\";\n  } else if (toolResultCount === 1) {\n    syntheticModelContent = \"[Tool execution completed.]\";\n  } else {\n    syntheticModelContent = `[${toolResultCount} tool executions completed.]`;\n  }\n\n  // Step 1: Inject synthetic MODEL message to complete the non-thinking turn\n  const syntheticModel = {\n    role: \"model\",\n    parts: [{ text: syntheticModelContent }],\n  };\n\n  // Step 2: Inject synthetic USER message to start a NEW turn\n  const syntheticUser = {\n    role: \"user\",\n    parts: [{ text: \"[Continue]\" }],\n  };\n\n  return [...strippedContents, syntheticModel, syntheticUser];\n}\n\n/**\n * Checks if conversation state requires tool loop closure for thinking recovery.\n *\n * Returns true if:\n * - We're in a tool loop (state.inToolLoop)\n * - The turn didn't start with thinking (state.turnHasThinking === false)\n *\n * This is the trigger for the \"let it crash and start again\" recovery.\n */\nexport function needsThinkingRecovery(state: ConversationState): boolean {\n  return state.inToolLoop && !state.turnHasThinking;\n}\n\n// ============================================================================\n// COMPACTED THINKING TURN DETECTION (Ported from LLM-API-Key-Proxy)\n// ============================================================================\n\n/**\n * Detects if a message looks like it was compacted from a thinking-enabled turn.\n * \n * This is a heuristic to distinguish between:\n * - \"Never had thinking\" (model didn't use thinking mode)\n * - \"Thinking was stripped\" (context compaction removed thinking blocks)\n * \n * Port of LLM-API-Key-Proxy's _looks_like_compacted_thinking_turn()\n * \n * Heuristics:\n * 1. Has functionCall parts (typical thinking flow produces tool calls)\n * 2. No thinking parts (thought: true)\n * 3. No text content before functionCall (thinking responses usually have text)\n * \n * @param msg - A single message from the conversation\n * @returns true if the message looks like thinking was stripped\n */\nexport function looksLikeCompactedThinkingTurn(msg: any): boolean {\n  if (!msg || typeof msg !== \"object\") return false;\n\n  const parts = msg.parts || [];\n  if (parts.length === 0) return false;\n\n  // Check if message has function calls\n  const hasFunctionCall = parts.some(\n    (p: any) => p && typeof p === \"object\" && p.functionCall,\n  );\n\n  if (!hasFunctionCall) return false;\n\n  // Check for thinking blocks\n  const hasThinking = parts.some(\n    (p: any) =>\n      p &&\n      typeof p === \"object\" &&\n      (p.thought === true || p.type === \"thinking\" || p.type === \"redacted_thinking\"),\n  );\n\n  if (hasThinking) return false;\n\n  // Check for text content (not thinking)\n  const hasTextBeforeFunctionCall = parts.some((p: any, idx: number) => {\n    if (!p || typeof p !== \"object\") return false;\n    // Only check parts before the first functionCall\n    const firstFuncIdx = parts.findIndex(\n      (fp: any) => fp && typeof fp === \"object\" && fp.functionCall,\n    );\n    if (idx >= firstFuncIdx) return false;\n    // Check for non-thinking text\n    return (\n      \"text\" in p &&\n      typeof p.text === \"string\" &&\n      p.text.trim().length > 0 &&\n      !p.thought\n    );\n  });\n\n  // If we have functionCall but no text before it, likely compacted\n  return !hasTextBeforeFunctionCall;\n}\n\n/**\n * Checks if any message in the current turn looks like it was compacted.\n * \n * @param contents - Full conversation contents\n * @param turnStartIdx - Index of the first model message in current turn\n * @returns true if any model message in the turn looks compacted\n */\nexport function hasPossibleCompactedThinking(\n  contents: any[],\n  turnStartIdx: number,\n): boolean {\n  if (!Array.isArray(contents) || turnStartIdx < 0) return false;\n\n  for (let i = turnStartIdx; i < contents.length; i++) {\n    const msg = contents[i];\n    if (msg?.role === \"model\" && looksLikeCompactedThinkingTurn(msg)) {\n      return true;\n    }\n  }\n\n  return false;\n}\n"
  },
  {
    "path": "src/plugin/token.test.ts",
    "content": "import { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nimport { ANTIGRAVITY_PROVIDER_ID } from \"../constants\";\nimport { AntigravityTokenRefreshError, refreshAccessToken } from \"./token\";\nimport type { OAuthAuthDetails, PluginClient } from \"./types\";\n\nconst baseAuth: OAuthAuthDetails = {\n  type: \"oauth\",\n  refresh: \"refresh-token|project-123\",\n  access: \"old-access\",\n  expires: Date.now() - 1000,\n};\n\nfunction createClient() {\n  return {\n    auth: {\n      set: vi.fn(async () => {}),\n    },\n  } as PluginClient & {\n    auth: { set: ReturnType<typeof vi.fn> };\n  };\n}\n\ndescribe(\"refreshAccessToken\", () => {\n  beforeEach(() => {\n    vi.restoreAllMocks();\n  });\n\n  it(\"updates the caller when refresh token is unchanged\", async () => {\n    const client = createClient();\n    const fetchMock = vi.fn(async () => {\n      return new Response(\n        JSON.stringify({\n          access_token: \"new-access\",\n          expires_in: 3600,\n        }),\n        { status: 200 },\n      );\n    });\n    global.fetch = fetchMock as unknown as typeof fetch;\n\n    const result = await refreshAccessToken(baseAuth, client, ANTIGRAVITY_PROVIDER_ID);\n\n    expect(result?.access).toBe(\"new-access\");\n    expect(client.auth.set.mock.calls.length).toBe(0);\n  });\n\n  it(\"handles Google refresh token rotation\", async () => {\n    const client = createClient();\n    const fetchMock = vi.fn(async () => {\n      return new Response(\n        JSON.stringify({\n          access_token: \"next-access\",\n          expires_in: 3600,\n          refresh_token: \"rotated-token\",\n        }),\n        { status: 200 },\n      );\n    });\n    global.fetch = fetchMock as unknown as typeof fetch;\n\n    const result = await refreshAccessToken(baseAuth, client, ANTIGRAVITY_PROVIDER_ID);\n\n    expect(result?.access).toBe(\"next-access\");\n    expect(result?.refresh).toContain(\"rotated-token\");\n    expect(client.auth.set.mock.calls.length).toBe(0);\n  });\n\n  it(\"throws a typed error on invalid_grant\", async () => {\n    const client = createClient();\n    const fetchMock = vi.fn(async () => {\n      return new Response(\n        JSON.stringify({\n          error: \"invalid_grant\",\n          error_description: \"Refresh token revoked\",\n        }),\n        { status: 400, statusText: \"Bad Request\" },\n      );\n    });\n    global.fetch = fetchMock as unknown as typeof fetch;\n\n    await expect(refreshAccessToken(baseAuth, client, ANTIGRAVITY_PROVIDER_ID)).rejects.toMatchObject({\n      name: \"AntigravityTokenRefreshError\",\n      code: \"invalid_grant\",\n    });\n  });\n});\n"
  },
  {
    "path": "src/plugin/token.ts",
    "content": "import { ANTIGRAVITY_CLIENT_ID, ANTIGRAVITY_CLIENT_SECRET } from \"../constants\";\nimport { formatRefreshParts, parseRefreshParts, calculateTokenExpiry } from \"./auth\";\nimport { clearCachedAuth, storeCachedAuth } from \"./cache\";\nimport { createLogger } from \"./logger\";\nimport { invalidateProjectContextCache } from \"./project\";\nimport type { OAuthAuthDetails, PluginClient, RefreshParts } from \"./types\";\n\nconst log = createLogger(\"token\");\n\ninterface OAuthErrorPayload {\n  error?:\n    | string\n    | {\n        code?: string;\n        status?: string;\n        message?: string;\n      };\n  error_description?: string;\n}\n\n/**\n * Parses OAuth error payloads returned by Google token endpoints, tolerating varied shapes.\n */\nfunction parseOAuthErrorPayload(text: string | undefined): { code?: string; description?: string } {\n  if (!text) {\n    return {};\n  }\n\n  try {\n    const payload = JSON.parse(text) as OAuthErrorPayload;\n    if (!payload || typeof payload !== \"object\") {\n      return { description: text };\n    }\n\n    let code: string | undefined;\n    if (typeof payload.error === \"string\") {\n      code = payload.error;\n    } else if (payload.error && typeof payload.error === \"object\") {\n      code = payload.error.status ?? payload.error.code;\n      if (!payload.error_description && payload.error.message) {\n        return { code, description: payload.error.message };\n      }\n    }\n\n    const description = payload.error_description;\n    if (description) {\n      return { code, description };\n    }\n\n    if (payload.error && typeof payload.error === \"object\" && payload.error.message) {\n      return { code, description: payload.error.message };\n    }\n\n    return { code };\n  } catch {\n    return { description: text };\n  }\n}\n\nexport class AntigravityTokenRefreshError extends Error {\n  code?: string;\n  description?: string;\n  status: number;\n  statusText: string;\n\n  constructor(options: {\n    message: string;\n    code?: string;\n    description?: string;\n    status: number;\n    statusText: string;\n  }) {\n    super(options.message);\n    this.name = \"AntigravityTokenRefreshError\";\n    this.code = options.code;\n    this.description = options.description;\n    this.status = options.status;\n    this.statusText = options.statusText;\n  }\n}\n\n/**\n * Refreshes an Antigravity OAuth access token, updates persisted credentials, and handles revocation.\n */\nexport async function refreshAccessToken(\n  auth: OAuthAuthDetails,\n  client: PluginClient,\n  providerId: string,\n): Promise<OAuthAuthDetails | undefined> {\n  const parts = parseRefreshParts(auth.refresh);\n  if (!parts.refreshToken) {\n    return undefined;\n  }\n\n  try {\n    const startTime = Date.now();\n    const response = await fetch(\"https://oauth2.googleapis.com/token\", {\n      method: \"POST\",\n      headers: {\n        \"Content-Type\": \"application/x-www-form-urlencoded\",\n      },\n      body: new URLSearchParams({\n        grant_type: \"refresh_token\",\n        refresh_token: parts.refreshToken,\n        client_id: ANTIGRAVITY_CLIENT_ID,\n        client_secret: ANTIGRAVITY_CLIENT_SECRET,\n      }),\n    });\n\n    if (!response.ok) {\n      let errorText: string | undefined;\n      try {\n        errorText = await response.text();\n      } catch {\n        errorText = undefined;\n      }\n\n      const { code, description } = parseOAuthErrorPayload(errorText);\n      const details = [code, description ?? errorText].filter(Boolean).join(\": \");\n      const baseMessage = `Antigravity token refresh failed (${response.status} ${response.statusText})`;\n      const message = details ? `${baseMessage} - ${details}` : baseMessage;\n      log.warn(\"Token refresh failed\", { status: response.status, code, details });\n\n      if (code === \"invalid_grant\") {\n        log.warn(\"Google revoked the stored refresh token - reauthentication required\");\n        invalidateProjectContextCache(auth.refresh);\n        clearCachedAuth(auth.refresh);\n      }\n\n      throw new AntigravityTokenRefreshError({\n        message,\n        code,\n        description: description ?? errorText,\n        status: response.status,\n        statusText: response.statusText,\n      });\n    }\n\n    const payload = (await response.json()) as {\n      access_token: string;\n      expires_in: number;\n      refresh_token?: string;\n    };\n\n    const refreshedParts: RefreshParts = {\n      refreshToken: payload.refresh_token ?? parts.refreshToken,\n      projectId: parts.projectId,\n      managedProjectId: parts.managedProjectId,\n    };\n\n    const updatedAuth: OAuthAuthDetails = {\n      ...auth,\n      access: payload.access_token,\n      expires: calculateTokenExpiry(startTime, payload.expires_in),\n      refresh: formatRefreshParts(refreshedParts),\n    };\n\n    storeCachedAuth(updatedAuth);\n    invalidateProjectContextCache(auth.refresh);\n\n    return updatedAuth;\n  } catch (error) {\n    if (error instanceof AntigravityTokenRefreshError) {\n      throw error;\n    }\n    log.error(\"Unexpected token refresh error\", { error: String(error) });\n    return undefined;\n  }\n}\n\n"
  },
  {
    "path": "src/plugin/transform/claude.test.ts",
    "content": "import { describe, it, expect } from \"vitest\";\nimport {\n  isClaudeModel,\n  isClaudeThinkingModel,\n  configureClaudeToolConfig,\n  buildClaudeThinkingConfig,\n  ensureClaudeMaxOutputTokens,\n  appendClaudeThinkingHint,\n  normalizeClaudeTools,\n  applyClaudeTransforms,\n  CLAUDE_THINKING_MAX_OUTPUT_TOKENS,\n  CLAUDE_INTERLEAVED_THINKING_HINT,\n} from \"./claude\";\nimport type { RequestPayload } from \"./types\";\n\ndescribe(\"isClaudeModel\", () => {\n  it(\"returns true for claude model names\", () => {\n    expect(isClaudeModel(\"claude-sonnet-4-5\")).toBe(true);\n    expect(isClaudeModel(\"claude-opus-4-5\")).toBe(true);\n    expect(isClaudeModel(\"claude-3-opus\")).toBe(true);\n    expect(isClaudeModel(\"claude-3-5-sonnet\")).toBe(true);\n  });\n\n  it(\"returns true for case-insensitive matches\", () => {\n    expect(isClaudeModel(\"CLAUDE-SONNET-4-5\")).toBe(true);\n    expect(isClaudeModel(\"Claude-Opus-4-5\")).toBe(true);\n    expect(isClaudeModel(\"cLaUdE-3-opus\")).toBe(true);\n  });\n\n  it(\"returns true for prefixed claude models\", () => {\n    expect(isClaudeModel(\"antigravity-claude-sonnet-4-5\")).toBe(true);\n    expect(isClaudeModel(\"google/claude-opus-4-5\")).toBe(true);\n  });\n\n  it(\"returns false for non-claude models\", () => {\n    expect(isClaudeModel(\"gemini-3-pro\")).toBe(false);\n    expect(isClaudeModel(\"gpt-4\")).toBe(false);\n    expect(isClaudeModel(\"llama-3\")).toBe(false);\n    expect(isClaudeModel(\"\")).toBe(false);\n  });\n\n  it(\"returns false for similar but non-claude names\", () => {\n    expect(isClaudeModel(\"claudia-model\")).toBe(false);\n    expect(isClaudeModel(\"clade-model\")).toBe(false);\n  });\n});\n\ndescribe(\"isClaudeThinkingModel\", () => {\n  it(\"returns true for claude thinking models\", () => {\n    expect(isClaudeThinkingModel(\"claude-sonnet-4-5-thinking\")).toBe(true);\n    expect(isClaudeThinkingModel(\"claude-opus-4-5-thinking\")).toBe(true);\n    expect(isClaudeThinkingModel(\"claude-sonnet-4-5-thinking-high\")).toBe(true);\n    expect(isClaudeThinkingModel(\"claude-opus-4-5-thinking-low\")).toBe(true);\n  });\n\n  it(\"returns true for case-insensitive matches\", () => {\n    expect(isClaudeThinkingModel(\"CLAUDE-SONNET-4-5-THINKING\")).toBe(true);\n    expect(isClaudeThinkingModel(\"Claude-Opus-4-5-Thinking\")).toBe(true);\n  });\n\n  it(\"returns true for prefixed thinking models\", () => {\n    expect(isClaudeThinkingModel(\"antigravity-claude-sonnet-4-5-thinking\")).toBe(true);\n    expect(isClaudeThinkingModel(\"google/claude-opus-4-5-thinking-high\")).toBe(true);\n  });\n\n  it(\"returns false for non-thinking claude models\", () => {\n    expect(isClaudeThinkingModel(\"claude-sonnet-4-5\")).toBe(false);\n    expect(isClaudeThinkingModel(\"claude-opus-4-5\")).toBe(false);\n    expect(isClaudeThinkingModel(\"claude-3-opus\")).toBe(false);\n  });\n\n  it(\"returns false for non-claude models\", () => {\n    expect(isClaudeThinkingModel(\"gemini-3-pro-thinking\")).toBe(false);\n    expect(isClaudeThinkingModel(\"gpt-4-thinking\")).toBe(false);\n  });\n\n  it(\"requires both claude and thinking keywords\", () => {\n    expect(isClaudeThinkingModel(\"thinking-model\")).toBe(false);\n    expect(isClaudeThinkingModel(\"claude-model\")).toBe(false);\n  });\n});\n\ndescribe(\"configureClaudeToolConfig\", () => {\n  it(\"creates toolConfig if not present\", () => {\n    const payload: RequestPayload = {};\n    configureClaudeToolConfig(payload);\n    \n    expect(payload.toolConfig).toBeDefined();\n    expect((payload.toolConfig as any).functionCallingConfig).toBeDefined();\n    expect((payload.toolConfig as any).functionCallingConfig.mode).toBe(\"VALIDATED\");\n  });\n\n  it(\"adds functionCallingConfig to existing toolConfig\", () => {\n    const payload: RequestPayload = {\n      toolConfig: { someOtherConfig: true },\n    };\n    configureClaudeToolConfig(payload);\n    \n    expect((payload.toolConfig as any).someOtherConfig).toBe(true);\n    expect((payload.toolConfig as any).functionCallingConfig.mode).toBe(\"VALIDATED\");\n  });\n\n  it(\"sets mode to VALIDATED on existing functionCallingConfig\", () => {\n    const payload: RequestPayload = {\n      toolConfig: {\n        functionCallingConfig: { existingKey: \"value\" },\n      },\n    };\n    configureClaudeToolConfig(payload);\n    \n    expect((payload.toolConfig as any).functionCallingConfig.existingKey).toBe(\"value\");\n    expect((payload.toolConfig as any).functionCallingConfig.mode).toBe(\"VALIDATED\");\n  });\n\n  it(\"overwrites existing mode\", () => {\n    const payload: RequestPayload = {\n      toolConfig: {\n        functionCallingConfig: { mode: \"AUTO\" },\n      },\n    };\n    configureClaudeToolConfig(payload);\n    \n    expect((payload.toolConfig as any).functionCallingConfig.mode).toBe(\"VALIDATED\");\n  });\n\n  it(\"handles null toolConfig gracefully\", () => {\n    const payload: RequestPayload = { toolConfig: null };\n    configureClaudeToolConfig(payload);\n    \n    expect(payload.toolConfig).toBeDefined();\n  });\n});\n\ndescribe(\"buildClaudeThinkingConfig\", () => {\n  it(\"builds config with include_thoughts only\", () => {\n    const config = buildClaudeThinkingConfig(true);\n    \n    expect(config).toEqual({ include_thoughts: true });\n  });\n\n  it(\"builds config with include_thoughts false\", () => {\n    const config = buildClaudeThinkingConfig(false);\n    \n    expect(config).toEqual({ include_thoughts: false });\n  });\n\n  it(\"includes thinking_budget when provided and positive\", () => {\n    const config = buildClaudeThinkingConfig(true, 8192);\n    \n    expect(config).toEqual({\n      include_thoughts: true,\n      thinking_budget: 8192,\n    });\n  });\n\n  it(\"excludes thinking_budget when zero\", () => {\n    const config = buildClaudeThinkingConfig(true, 0);\n    \n    expect(config).toEqual({ include_thoughts: true });\n  });\n\n  it(\"excludes thinking_budget when negative\", () => {\n    const config = buildClaudeThinkingConfig(true, -100);\n    \n    expect(config).toEqual({ include_thoughts: true });\n  });\n\n  it(\"excludes thinking_budget when undefined\", () => {\n    const config = buildClaudeThinkingConfig(true, undefined);\n    \n    expect(config).toEqual({ include_thoughts: true });\n  });\n\n  it(\"handles various budget values\", () => {\n    expect(buildClaudeThinkingConfig(true, 8192)).toHaveProperty(\"thinking_budget\", 8192);\n    expect(buildClaudeThinkingConfig(true, 16384)).toHaveProperty(\"thinking_budget\", 16384);\n    expect(buildClaudeThinkingConfig(true, 32768)).toHaveProperty(\"thinking_budget\", 32768);\n  });\n});\n\ndescribe(\"ensureClaudeMaxOutputTokens\", () => {\n  it(\"sets maxOutputTokens when not present\", () => {\n    const config: Record<string, unknown> = {};\n    ensureClaudeMaxOutputTokens(config, 8192);\n    \n    expect(config.maxOutputTokens).toBe(CLAUDE_THINKING_MAX_OUTPUT_TOKENS);\n  });\n\n  it(\"sets maxOutputTokens when current is less than budget\", () => {\n    const config: Record<string, unknown> = { maxOutputTokens: 4096 };\n    ensureClaudeMaxOutputTokens(config, 8192);\n    \n    expect(config.maxOutputTokens).toBe(CLAUDE_THINKING_MAX_OUTPUT_TOKENS);\n  });\n\n  it(\"sets maxOutputTokens when current equals budget\", () => {\n    const config: Record<string, unknown> = { maxOutputTokens: 8192 };\n    ensureClaudeMaxOutputTokens(config, 8192);\n    \n    expect(config.maxOutputTokens).toBe(CLAUDE_THINKING_MAX_OUTPUT_TOKENS);\n  });\n\n  it(\"does not change maxOutputTokens when current is greater than budget\", () => {\n    const config: Record<string, unknown> = { maxOutputTokens: 100000 };\n    ensureClaudeMaxOutputTokens(config, 8192);\n    \n    expect(config.maxOutputTokens).toBe(100000);\n  });\n\n  it(\"handles snake_case max_output_tokens\", () => {\n    const config: Record<string, unknown> = { max_output_tokens: 4096 };\n    ensureClaudeMaxOutputTokens(config, 8192);\n    \n    expect(config.maxOutputTokens).toBe(CLAUDE_THINKING_MAX_OUTPUT_TOKENS);\n    expect(config.max_output_tokens).toBeUndefined();\n  });\n\n  it(\"removes max_output_tokens when setting maxOutputTokens\", () => {\n    const config: Record<string, unknown> = { \n      max_output_tokens: 4096,\n      maxOutputTokens: 4096,\n    };\n    ensureClaudeMaxOutputTokens(config, 8192);\n    \n    expect(config.maxOutputTokens).toBe(CLAUDE_THINKING_MAX_OUTPUT_TOKENS);\n    expect(config.max_output_tokens).toBeUndefined();\n  });\n\n  it(\"prefers maxOutputTokens over max_output_tokens for comparison\", () => {\n    const config: Record<string, unknown> = { \n      maxOutputTokens: 100000,\n      max_output_tokens: 4096,\n    };\n    ensureClaudeMaxOutputTokens(config, 8192);\n    \n    expect(config.maxOutputTokens).toBe(100000);\n  });\n});\n\ndescribe(\"appendClaudeThinkingHint\", () => {\n  describe(\"with string systemInstruction\", () => {\n    it(\"appends hint to existing string instruction\", () => {\n      const payload: RequestPayload = {\n        systemInstruction: \"You are a helpful assistant.\",\n      };\n      appendClaudeThinkingHint(payload);\n      \n      expect(payload.systemInstruction).toBe(\n        `You are a helpful assistant.\\n\\n${CLAUDE_INTERLEAVED_THINKING_HINT}`\n      );\n    });\n\n    it(\"uses hint alone when existing instruction is empty\", () => {\n      const payload: RequestPayload = {\n        systemInstruction: \"\",\n      };\n      appendClaudeThinkingHint(payload);\n      \n      expect(payload.systemInstruction).toBe(CLAUDE_INTERLEAVED_THINKING_HINT);\n    });\n\n    it(\"uses hint alone when existing instruction is whitespace\", () => {\n      const payload: RequestPayload = {\n        systemInstruction: \"   \",\n      };\n      appendClaudeThinkingHint(payload);\n      \n      expect(payload.systemInstruction).toBe(CLAUDE_INTERLEAVED_THINKING_HINT);\n    });\n\n    it(\"accepts custom hint\", () => {\n      const payload: RequestPayload = {\n        systemInstruction: \"Base instruction.\",\n      };\n      appendClaudeThinkingHint(payload, \"Custom hint.\");\n      \n      expect(payload.systemInstruction).toBe(\"Base instruction.\\n\\nCustom hint.\");\n    });\n  });\n\n  describe(\"with object systemInstruction (parts array)\", () => {\n    it(\"appends hint to last text part\", () => {\n      const payload: RequestPayload = {\n        systemInstruction: {\n          parts: [{ text: \"First part.\" }, { text: \"Last part.\" }],\n        },\n      };\n      appendClaudeThinkingHint(payload);\n      \n      const sys = payload.systemInstruction as any;\n      expect(sys.parts[0].text).toBe(\"First part.\");\n      expect(sys.parts[1].text).toBe(`Last part.\\n\\n${CLAUDE_INTERLEAVED_THINKING_HINT}`);\n    });\n\n    it(\"appends hint to single text part\", () => {\n      const payload: RequestPayload = {\n        systemInstruction: {\n          parts: [{ text: \"Only part.\" }],\n        },\n      };\n      appendClaudeThinkingHint(payload);\n      \n      const sys = payload.systemInstruction as any;\n      expect(sys.parts[0].text).toBe(`Only part.\\n\\n${CLAUDE_INTERLEAVED_THINKING_HINT}`);\n    });\n\n    it(\"creates new text part when no text parts exist\", () => {\n      const payload: RequestPayload = {\n        systemInstruction: {\n          parts: [{ image: \"base64data\" }],\n        },\n      };\n      appendClaudeThinkingHint(payload);\n      \n      const sys = payload.systemInstruction as any;\n      expect(sys.parts).toHaveLength(2);\n      expect(sys.parts[1].text).toBe(CLAUDE_INTERLEAVED_THINKING_HINT);\n    });\n\n    it(\"creates parts array when not present\", () => {\n      const payload: RequestPayload = {\n        systemInstruction: { role: \"system\" },\n      };\n      appendClaudeThinkingHint(payload);\n      \n      const sys = payload.systemInstruction as any;\n      expect(sys.parts).toEqual([{ text: CLAUDE_INTERLEAVED_THINKING_HINT }]);\n    });\n  });\n\n  describe(\"with no systemInstruction\", () => {\n    it(\"creates systemInstruction when contents array exists\", () => {\n      const payload: RequestPayload = {\n        contents: [{ role: \"user\", parts: [{ text: \"Hello\" }] }],\n      };\n      appendClaudeThinkingHint(payload);\n      \n      expect(payload.systemInstruction).toEqual({\n        parts: [{ text: CLAUDE_INTERLEAVED_THINKING_HINT }],\n      });\n    });\n\n    it(\"does not create systemInstruction when no contents\", () => {\n      const payload: RequestPayload = {};\n      appendClaudeThinkingHint(payload);\n      \n      expect(payload.systemInstruction).toBeUndefined();\n    });\n  });\n});\n\ndescribe(\"normalizeClaudeTools\", () => {\n  const identityClean = (schema: unknown) => schema as Record<string, unknown>;\n  \n  const realClean = (schema: unknown): Record<string, unknown> => {\n    if (!schema || typeof schema !== \"object\") return {};\n    const cleaned = { ...schema as Record<string, unknown> };\n    delete cleaned.$schema;\n    delete cleaned.$id;\n    return cleaned;\n  };\n\n  it(\"returns empty result when no tools\", () => {\n    const payload: RequestPayload = {};\n    const result = normalizeClaudeTools(payload, identityClean);\n    \n    expect(result.toolDebugMissing).toBe(0);\n    expect(result.toolDebugSummaries).toEqual([]);\n  });\n\n  it(\"returns empty result when tools is not an array\", () => {\n    const payload: RequestPayload = { tools: \"not an array\" };\n    const result = normalizeClaudeTools(payload, identityClean);\n    \n    expect(result.toolDebugMissing).toBe(0);\n    expect(result.toolDebugSummaries).toEqual([]);\n  });\n\n  describe(\"functionDeclarations format\", () => {\n    it(\"normalizes tools with functionDeclarations array\", () => {\n      const payload: RequestPayload = {\n        tools: [{\n          functionDeclarations: [{\n            name: \"get_weather\",\n            description: \"Get weather for a location\",\n            parameters: {\n              type: \"object\",\n              properties: {\n                location: { type: \"string\" },\n              },\n              required: [\"location\"],\n            },\n          }],\n        }],\n      };\n      \n      const result = normalizeClaudeTools(payload, identityClean);\n      \n      expect(result.toolDebugMissing).toBe(0);\n      expect(result.toolDebugSummaries).toContain(\"decl=get_weather,src=functionDeclarations,hasSchema=y\");\n      \n      const tools = payload.tools as any[];\n      expect(tools).toHaveLength(1);\n      expect(tools[0].functionDeclarations).toHaveLength(1);\n      expect(tools[0].functionDeclarations[0].name).toBe(\"get_weather\");\n    });\n\n    it(\"handles multiple functionDeclarations\", () => {\n      const payload: RequestPayload = {\n        tools: [{\n          functionDeclarations: [\n            { name: \"tool1\", description: \"First tool\" },\n            { name: \"tool2\", description: \"Second tool\" },\n          ],\n        }],\n      };\n      \n      normalizeClaudeTools(payload, identityClean);\n      \n      const tools = payload.tools as any[];\n      expect(tools[0].functionDeclarations).toHaveLength(2);\n    });\n  });\n\n  describe(\"function/custom format\", () => {\n    it(\"normalizes OpenAI-style function tools\", () => {\n      const payload: RequestPayload = {\n        tools: [{\n          type: \"function\",\n          function: {\n            name: \"search\",\n            description: \"Search the web\",\n            parameters: {\n              type: \"object\",\n              properties: {\n                query: { type: \"string\" },\n              },\n            },\n          },\n        }],\n      };\n      \n      const result = normalizeClaudeTools(payload, identityClean);\n      \n      expect(result.toolDebugSummaries).toContain(\"decl=search,src=function/custom,hasSchema=y\");\n      \n      const tools = payload.tools as any[];\n      expect(tools[0].functionDeclarations[0].name).toBe(\"search\");\n    });\n\n    it(\"normalizes custom-style tools\", () => {\n      const payload: RequestPayload = {\n        tools: [{\n          custom: {\n            name: \"custom_tool\",\n            description: \"A custom tool\",\n            input_schema: {\n              type: \"object\",\n              properties: { arg: { type: \"string\" } },\n            },\n          },\n        }],\n      };\n      \n      const result = normalizeClaudeTools(payload, identityClean);\n      \n      expect(result.toolDebugSummaries).toContain(\"decl=custom_tool,src=function/custom,hasSchema=y\");\n    });\n\n    it(\"normalizes tools with top-level name/parameters\", () => {\n      const payload: RequestPayload = {\n        tools: [{\n          name: \"direct_tool\",\n          description: \"Direct definition\",\n          parameters: {\n            type: \"object\",\n            properties: { value: { type: \"number\" } },\n          },\n        }],\n      };\n      \n      normalizeClaudeTools(payload, identityClean);\n      \n      const tools = payload.tools as any[];\n      expect(tools[0].functionDeclarations[0].name).toBe(\"direct_tool\");\n    });\n  });\n\n  describe(\"schema normalization\", () => {\n    it(\"adds placeholder when schema is missing\", () => {\n      const payload: RequestPayload = {\n        tools: [{\n          function: {\n            name: \"no_schema_tool\",\n            description: \"Tool without schema\",\n          },\n        }],\n      };\n      \n      const result = normalizeClaudeTools(payload, identityClean);\n      \n      expect(result.toolDebugMissing).toBe(1);\n      \n      const tools = payload.tools as any[];\n      const params = tools[0].functionDeclarations[0].parameters;\n      expect(params.type).toBe(\"object\");\n      expect(params.properties._placeholder).toBeDefined();\n      expect(params.required).toContain(\"_placeholder\");\n    });\n\n    it(\"adds placeholder when schema has no properties\", () => {\n      const payload: RequestPayload = {\n        tools: [{\n          function: {\n            name: \"empty_schema_tool\",\n            parameters: { type: \"object\" },\n          },\n        }],\n      };\n      \n      normalizeClaudeTools(payload, identityClean);\n      \n      const tools = payload.tools as any[];\n      const params = tools[0].functionDeclarations[0].parameters;\n      expect(params.properties._placeholder).toBeDefined();\n    });\n\n    it(\"preserves existing properties\", () => {\n      const payload: RequestPayload = {\n        tools: [{\n          function: {\n            name: \"has_props_tool\",\n            parameters: {\n              type: \"object\",\n              properties: {\n                existingProp: { type: \"string\" },\n              },\n            },\n          },\n        }],\n      };\n      \n      normalizeClaudeTools(payload, identityClean);\n      \n      const tools = payload.tools as any[];\n      const params = tools[0].functionDeclarations[0].parameters;\n      expect(params.properties.existingProp).toBeDefined();\n      expect(params.properties._placeholder).toBeUndefined();\n    });\n\n    it(\"cleans schema using provided function\", () => {\n      const payload: RequestPayload = {\n        tools: [{\n          function: {\n            name: \"needs_cleaning\",\n            parameters: {\n              $schema: \"http://json-schema.org/draft-07/schema#\",\n              type: \"object\",\n              properties: { arg: { type: \"string\" } },\n            },\n          },\n        }],\n      };\n      \n      normalizeClaudeTools(payload, realClean);\n      \n      const tools = payload.tools as any[];\n      const params = tools[0].functionDeclarations[0].parameters;\n      expect(params.$schema).toBeUndefined();\n      expect(params.properties.arg).toBeDefined();\n    });\n  });\n\n  describe(\"tool name sanitization\", () => {\n    it(\"removes special characters from tool names\", () => {\n      const payload: RequestPayload = {\n        tools: [{\n          function: {\n            name: \"tool@with#special$chars!\",\n            parameters: { type: \"object\", properties: { x: { type: \"string\" } } },\n          },\n        }],\n      };\n      \n      normalizeClaudeTools(payload, identityClean);\n      \n      const tools = payload.tools as any[];\n      expect(tools[0].functionDeclarations[0].name).toBe(\"tool_with_special_chars_\");\n    });\n\n    it(\"truncates long tool names to 64 characters\", () => {\n      const longName = \"a\".repeat(100);\n      const payload: RequestPayload = {\n        tools: [{\n          function: {\n            name: longName,\n            parameters: { type: \"object\", properties: { x: { type: \"string\" } } },\n          },\n        }],\n      };\n      \n      normalizeClaudeTools(payload, identityClean);\n      \n      const tools = payload.tools as any[];\n      expect(tools[0].functionDeclarations[0].name).toHaveLength(64);\n    });\n\n    it(\"generates name when missing\", () => {\n      const payload: RequestPayload = {\n        tools: [{\n          function: {\n            description: \"Nameless tool\",\n            parameters: { type: \"object\", properties: { x: { type: \"string\" } } },\n          },\n        }],\n      };\n      \n      normalizeClaudeTools(payload, identityClean);\n      \n      const tools = payload.tools as any[];\n      expect(tools[0].functionDeclarations[0].name).toBe(\"tool-0\");\n    });\n  });\n\n  describe(\"passthrough tools\", () => {\n    it(\"preserves non-function tools like codeExecution\", () => {\n      const payload: RequestPayload = {\n        tools: [\n          { codeExecution: {} },\n          {\n            function: {\n              name: \"regular_tool\",\n              parameters: { type: \"object\", properties: { x: { type: \"string\" } } },\n            },\n          },\n        ],\n      };\n      \n      normalizeClaudeTools(payload, identityClean);\n      \n      const tools = payload.tools as any[];\n      expect(tools).toHaveLength(2);\n      expect(tools[0].functionDeclarations).toBeDefined();\n      expect(tools[1].codeExecution).toBeDefined();\n    });\n  });\n});\n\ndescribe(\"applyClaudeTransforms\", () => {\n  const mockCleanJSONSchema = (schema: unknown) => schema as Record<string, unknown>;\n\n  it(\"applies tool config for all Claude models\", () => {\n    const payload: RequestPayload = {};\n    \n    applyClaudeTransforms(payload, {\n      model: \"claude-sonnet-4-6\",\n      cleanJSONSchema: mockCleanJSONSchema,\n    });\n    \n    expect((payload.toolConfig as any)?.functionCallingConfig?.mode).toBe(\"VALIDATED\");\n  });\n\n  it(\"applies thinking config for thinking models\", () => {\n    const payload: RequestPayload = {};\n    \n    applyClaudeTransforms(payload, {\n      model: \"claude-opus-4-6-thinking\",\n      normalizedThinking: { includeThoughts: true, thinkingBudget: 8192 },\n      cleanJSONSchema: mockCleanJSONSchema,\n    });\n    \n    const genConfig = payload.generationConfig as any;\n    expect(genConfig.thinkingConfig.include_thoughts).toBe(true);\n    expect(genConfig.thinkingConfig.thinking_budget).toBe(8192);\n  });\n\n  it(\"uses tierThinkingBudget over normalizedThinking.thinkingBudget\", () => {\n    const payload: RequestPayload = {};\n    \n    applyClaudeTransforms(payload, {\n      model: \"claude-opus-4-6-thinking\",\n      tierThinkingBudget: 32768,\n      normalizedThinking: { includeThoughts: true, thinkingBudget: 8192 },\n      cleanJSONSchema: mockCleanJSONSchema,\n    });\n    \n    const genConfig = payload.generationConfig as any;\n    expect(genConfig.thinkingConfig.thinking_budget).toBe(32768);\n  });\n\n  it(\"ensures maxOutputTokens for thinking models with budget\", () => {\n    const payload: RequestPayload = {\n      generationConfig: { maxOutputTokens: 4096 },\n    };\n    \n    applyClaudeTransforms(payload, {\n      model: \"claude-opus-4-6-thinking\",\n      normalizedThinking: { includeThoughts: true, thinkingBudget: 8192 },\n      cleanJSONSchema: mockCleanJSONSchema,\n    });\n    \n    const genConfig = payload.generationConfig as any;\n    expect(genConfig.maxOutputTokens).toBe(CLAUDE_THINKING_MAX_OUTPUT_TOKENS);\n  });\n\n  it(\"does not apply thinking config for non-thinking models\", () => {\n    const payload: RequestPayload = {};\n    \n    applyClaudeTransforms(payload, {\n      model: \"claude-sonnet-4-6\",\n      normalizedThinking: { includeThoughts: true, thinkingBudget: 8192 },\n      cleanJSONSchema: mockCleanJSONSchema,\n    });\n    \n    const genConfig = payload.generationConfig as any;\n    expect(genConfig?.thinkingConfig).toBeUndefined();\n  });\n\n  it(\"appends thinking hint for thinking models with tools\", () => {\n    const payload: RequestPayload = {\n      systemInstruction: \"You are helpful.\",\n      tools: [{ function: { name: \"test\", parameters: { type: \"object\", properties: { x: { type: \"string\" } } } } }],\n    };\n    \n    applyClaudeTransforms(payload, {\n      model: \"claude-opus-4-6-thinking\",\n      cleanJSONSchema: mockCleanJSONSchema,\n    });\n    \n    expect((payload.systemInstruction as string)).toContain(CLAUDE_INTERLEAVED_THINKING_HINT);\n  });\n\n  it(\"does not append thinking hint for thinking models without tools\", () => {\n    const payload: RequestPayload = {\n      systemInstruction: \"You are helpful.\",\n    };\n    \n    applyClaudeTransforms(payload, {\n      model: \"claude-opus-4-6-thinking\",\n      cleanJSONSchema: mockCleanJSONSchema,\n    });\n    \n    expect((payload.systemInstruction as string)).not.toContain(CLAUDE_INTERLEAVED_THINKING_HINT);\n  });\n\n  it(\"does not append thinking hint for non-thinking models with tools\", () => {\n    const payload: RequestPayload = {\n      systemInstruction: \"You are helpful.\",\n      tools: [{ function: { name: \"test\", parameters: { type: \"object\", properties: { x: { type: \"string\" } } } } }],\n    };\n    \n    applyClaudeTransforms(payload, {\n      model: \"claude-sonnet-4-6\",\n      cleanJSONSchema: mockCleanJSONSchema,\n    });\n    \n    expect((payload.systemInstruction as string)).not.toContain(CLAUDE_INTERLEAVED_THINKING_HINT);\n  });\n\n  it(\"normalizes tools and returns debug info\", () => {\n    const payload: RequestPayload = {\n      tools: [{ function: { name: \"my_tool\" } }],\n    };\n    \n    const result = applyClaudeTransforms(payload, {\n      model: \"claude-sonnet-4-6\",\n      cleanJSONSchema: mockCleanJSONSchema,\n    });\n    \n    expect(result.toolDebugMissing).toBe(1);\n    expect(result.toolDebugSummaries).toContain(\"decl=my_tool,src=function/custom,hasSchema=n\");\n  });\n\n  it(\"converts stop_sequences in generationConfig\", () => {\n    const payload: RequestPayload = {\n      generationConfig: { stop_sequences: [\"END\"] },\n    };\n    \n    applyClaudeTransforms(payload, {\n      model: \"claude-sonnet-4-6\",\n      cleanJSONSchema: mockCleanJSONSchema,\n    });\n    \n    const genConfig = payload.generationConfig as any;\n    expect(genConfig.stopSequences).toEqual([\"END\"]);\n    expect(genConfig.stop_sequences).toBeUndefined();\n  });\n});\n\ndescribe(\"constants\", () => {\n  it(\"exports CLAUDE_THINKING_MAX_OUTPUT_TOKENS\", () => {\n    expect(CLAUDE_THINKING_MAX_OUTPUT_TOKENS).toBe(64_000);\n  });\n\n  it(\"exports CLAUDE_INTERLEAVED_THINKING_HINT\", () => {\n    expect(CLAUDE_INTERLEAVED_THINKING_HINT).toContain(\"Interleaved thinking is enabled\");\n  });\n});\n"
  },
  {
    "path": "src/plugin/transform/claude.ts",
    "content": "/**\n * Claude-specific Request Transformations\n * \n * Handles Claude model-specific request transformations including:\n * - Tool config (VALIDATED mode)\n * - Thinking config (snake_case keys)\n * - System instruction hints for interleaved thinking\n * - Tool normalization (functionDeclarations format)\n */\n\nimport type { RequestPayload, ThinkingConfig } from \"./types\";\nimport {\n  EMPTY_SCHEMA_PLACEHOLDER_NAME,\n  EMPTY_SCHEMA_PLACEHOLDER_DESCRIPTION,\n} from \"../../constants\";\n\n/** Claude thinking models need a sufficiently large max output token limit when thinking is enabled */\nexport const CLAUDE_THINKING_MAX_OUTPUT_TOKENS = 64_000;\n\n/** Interleaved thinking hint appended to system instructions */\nexport const CLAUDE_INTERLEAVED_THINKING_HINT = \n  \"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.\";\n\n/**\n * Check if a model is a Claude model.\n */\nexport function isClaudeModel(model: string): boolean {\n  return model.toLowerCase().includes(\"claude\");\n}\n\n/**\n * Check if a model is a Claude thinking model.\n */\nexport function isClaudeThinkingModel(model: string): boolean {\n  const lower = model.toLowerCase();\n  return lower.includes(\"claude\") && lower.includes(\"thinking\");\n}\n\n/**\n * Configure Claude tool calling to use VALIDATED mode.\n * This ensures proper tool call validation on the backend.\n */\nexport function configureClaudeToolConfig(payload: RequestPayload): void {\n  if (!payload.toolConfig) {\n    payload.toolConfig = {};\n  }\n  \n  if (typeof payload.toolConfig === \"object\" && payload.toolConfig !== null) {\n    const toolConfig = payload.toolConfig as Record<string, unknown>;\n    if (!toolConfig.functionCallingConfig) {\n      toolConfig.functionCallingConfig = {};\n    }\n    if (typeof toolConfig.functionCallingConfig === \"object\" && toolConfig.functionCallingConfig !== null) {\n      (toolConfig.functionCallingConfig as Record<string, unknown>).mode = \"VALIDATED\";\n    }\n  }\n}\n\n/**\n * Build Claude thinking config with snake_case keys.\n */\nexport function buildClaudeThinkingConfig(\n  includeThoughts: boolean,\n  thinkingBudget?: number,\n): ThinkingConfig {\n  return {\n    include_thoughts: includeThoughts,\n    ...(typeof thinkingBudget === \"number\" && thinkingBudget > 0\n      ? { thinking_budget: thinkingBudget }\n      : {}),\n  } as unknown as ThinkingConfig;\n}\n\n/**\n * Ensure maxOutputTokens is sufficient for Claude thinking models.\n * If thinking budget is set, max output must be larger than the budget.\n */\nexport function ensureClaudeMaxOutputTokens(\n  generationConfig: Record<string, unknown>,\n  thinkingBudget: number,\n): void {\n  const currentMax = (generationConfig.maxOutputTokens ?? generationConfig.max_output_tokens) as number | undefined;\n  \n  if (!currentMax || currentMax <= thinkingBudget) {\n    generationConfig.maxOutputTokens = CLAUDE_THINKING_MAX_OUTPUT_TOKENS;\n    if (generationConfig.max_output_tokens !== undefined) {\n      delete generationConfig.max_output_tokens;\n    }\n  }\n}\n\n/**\n * Append interleaved thinking hint to system instruction.\n * Handles various system instruction formats (string, object with parts array).\n */\nexport function appendClaudeThinkingHint(\n  payload: RequestPayload,\n  hint: string = CLAUDE_INTERLEAVED_THINKING_HINT,\n): void {\n  const existing = payload.systemInstruction;\n\n  if (typeof existing === \"string\") {\n    payload.systemInstruction = existing.trim().length > 0 ? `${existing}\\n\\n${hint}` : hint;\n  } else if (existing && typeof existing === \"object\") {\n    const sys = existing as Record<string, unknown>;\n    const partsValue = sys.parts;\n\n    if (Array.isArray(partsValue)) {\n      const parts = partsValue as unknown[];\n      let appended = false;\n\n      // Find the last text part and append to it\n      for (let i = parts.length - 1; i >= 0; i--) {\n        const part = parts[i];\n        if (part && typeof part === \"object\") {\n          const partRecord = part as Record<string, unknown>;\n          const text = partRecord.text;\n          if (typeof text === \"string\") {\n            partRecord.text = `${text}\\n\\n${hint}`;\n            appended = true;\n            break;\n          }\n        }\n      }\n\n      if (!appended) {\n        parts.push({ text: hint });\n      }\n    } else {\n      sys.parts = [{ text: hint }];\n    }\n\n    payload.systemInstruction = sys;\n  } else if (Array.isArray(payload.contents)) {\n    // No existing system instruction, create one\n    payload.systemInstruction = { parts: [{ text: hint }] };\n  }\n}\n\n/**\n * Normalize tools for Claude models.\n * Converts various tool formats to functionDeclarations format.\n * \n * @returns Debug info about tool normalization\n */\nexport function normalizeClaudeTools(\n  payload: RequestPayload,\n  cleanJSONSchema: (schema: unknown) => Record<string, unknown>,\n): { toolDebugMissing: number; toolDebugSummaries: string[] } {\n  let toolDebugMissing = 0;\n  const toolDebugSummaries: string[] = [];\n\n  if (!Array.isArray(payload.tools)) {\n    return { toolDebugMissing, toolDebugSummaries };\n  }\n\n  const functionDeclarations: unknown[] = [];\n  const passthroughTools: unknown[] = [];\n\n  const normalizeSchema = (schema: unknown): Record<string, unknown> => {\n    const createPlaceholderSchema = (base: Record<string, unknown> = {}): Record<string, unknown> => ({\n      ...base,\n      type: \"object\",\n      properties: {\n        [EMPTY_SCHEMA_PLACEHOLDER_NAME]: {\n          type: \"boolean\",\n          description: EMPTY_SCHEMA_PLACEHOLDER_DESCRIPTION,\n        },\n      },\n      required: [EMPTY_SCHEMA_PLACEHOLDER_NAME],\n    });\n\n    if (!schema || typeof schema !== \"object\" || Array.isArray(schema)) {\n      toolDebugMissing += 1;\n      return createPlaceholderSchema();\n    }\n\n    const cleaned = cleanJSONSchema(schema);\n\n    if (!cleaned || typeof cleaned !== \"object\" || Array.isArray(cleaned)) {\n      toolDebugMissing += 1;\n      return createPlaceholderSchema();\n    }\n\n    // Claude VALIDATED mode requires tool parameters to be an object schema\n    // with at least one property.\n    const hasProperties =\n      cleaned.properties &&\n      typeof cleaned.properties === \"object\" &&\n      Object.keys(cleaned.properties as Record<string, unknown>).length > 0;\n\n    cleaned.type = \"object\";\n\n    if (!hasProperties) {\n      cleaned.properties = {\n        _placeholder: {\n          type: \"boolean\",\n          description: \"Placeholder. Always pass true.\",\n        },\n      };\n      cleaned.required = Array.isArray(cleaned.required)\n        ? Array.from(new Set([...(cleaned.required as string[]), \"_placeholder\"]))\n        : [\"_placeholder\"];\n    }\n\n    return cleaned;\n  };\n\n  (payload.tools as unknown[]).forEach((tool: unknown) => {\n    const t = tool as Record<string, unknown>;\n\n    const pushDeclaration = (decl: Record<string, unknown> | undefined, source: string): void => {\n      const schema =\n        decl?.parameters ||\n        decl?.parametersJsonSchema ||\n        decl?.input_schema ||\n        decl?.inputSchema ||\n        t.parameters ||\n        t.parametersJsonSchema ||\n        t.input_schema ||\n        t.inputSchema ||\n        (t.function as Record<string, unknown> | undefined)?.parameters ||\n        (t.function as Record<string, unknown> | undefined)?.parametersJsonSchema ||\n        (t.function as Record<string, unknown> | undefined)?.input_schema ||\n        (t.function as Record<string, unknown> | undefined)?.inputSchema ||\n        (t.custom as Record<string, unknown> | undefined)?.parameters ||\n        (t.custom as Record<string, unknown> | undefined)?.parametersJsonSchema ||\n        (t.custom as Record<string, unknown> | undefined)?.input_schema;\n\n      let name =\n        decl?.name ||\n        t.name ||\n        (t.function as Record<string, unknown> | undefined)?.name ||\n        (t.custom as Record<string, unknown> | undefined)?.name ||\n        `tool-${functionDeclarations.length}`;\n\n      // Sanitize tool name: must be alphanumeric with underscores, no special chars\n      name = String(name).replace(/[^a-zA-Z0-9_-]/g, \"_\").slice(0, 64);\n\n      const description =\n        decl?.description ||\n        t.description ||\n        (t.function as Record<string, unknown> | undefined)?.description ||\n        (t.custom as Record<string, unknown> | undefined)?.description ||\n        \"\";\n\n      functionDeclarations.push({\n        name,\n        description: String(description || \"\"),\n        parameters: normalizeSchema(schema),\n      });\n\n      toolDebugSummaries.push(\n        `decl=${name},src=${source},hasSchema=${schema ? \"y\" : \"n\"}`,\n      );\n    };\n\n    // Check for functionDeclarations array first\n    if (Array.isArray(t.functionDeclarations) && (t.functionDeclarations as unknown[]).length > 0) {\n      (t.functionDeclarations as Record<string, unknown>[]).forEach((decl) => \n        pushDeclaration(decl, \"functionDeclarations\")\n      );\n      return;\n    }\n\n    // Fall back to function/custom style definitions\n    if (t.function || t.custom || t.parameters || t.input_schema || t.inputSchema) {\n      pushDeclaration(\n        (t.function as Record<string, unknown> | undefined) ?? \n        (t.custom as Record<string, unknown> | undefined) ?? \n        t,\n        \"function/custom\"\n      );\n      return;\n    }\n\n    // Preserve any non-function tool entries (e.g., codeExecution) untouched\n    passthroughTools.push(tool);\n  });\n\n  const finalTools: unknown[] = [];\n  if (functionDeclarations.length > 0) {\n    finalTools.push({ functionDeclarations });\n  }\n  payload.tools = finalTools.concat(passthroughTools);\n\n  return { toolDebugMissing, toolDebugSummaries };\n}\n\n/**\n * Convert snake_case stop_sequences to camelCase stopSequences.\n */\nexport function convertStopSequences(\n  generationConfig: Record<string, unknown>,\n): void {\n  if (Array.isArray(generationConfig.stop_sequences)) {\n    generationConfig.stopSequences = generationConfig.stop_sequences;\n    delete generationConfig.stop_sequences;\n  }\n}\n\n/**\n * Apply all Claude-specific transformations to a request payload.\n */\nexport interface ClaudeTransformOptions {\n  /** The effective model name (resolved) */\n  model: string;\n  /** Tier-based thinking budget (from model suffix) */\n  tierThinkingBudget?: number;\n  /** Normalized thinking config from user settings */\n  normalizedThinking?: { includeThoughts?: boolean; thinkingBudget?: number };\n  /** Function to clean JSON schema for Antigravity */\n  cleanJSONSchema: (schema: unknown) => Record<string, unknown>;\n}\n\nexport interface ClaudeTransformResult {\n  toolDebugMissing: number;\n  toolDebugSummaries: string[];\n}\n\n/**\n * Apply all Claude-specific transformations.\n */\nexport function applyClaudeTransforms(\n  payload: RequestPayload,\n  options: ClaudeTransformOptions,\n): ClaudeTransformResult {\n  const { model, tierThinkingBudget, normalizedThinking, cleanJSONSchema } = options;\n  const isThinking = isClaudeThinkingModel(model);\n\n  // 1. Configure tool calling mode\n  configureClaudeToolConfig(payload);\n\n  if (payload.generationConfig) {\n    convertStopSequences(payload.generationConfig as Record<string, unknown>);\n  }\n\n  // 2. Apply thinking config if needed\n  if (normalizedThinking) {\n    const thinkingBudget = tierThinkingBudget ?? normalizedThinking.thinkingBudget;\n    \n    if (isThinking) {\n      const thinkingConfig = buildClaudeThinkingConfig(\n        normalizedThinking.includeThoughts ?? true,\n        thinkingBudget,\n      );\n\n      const generationConfig = (payload.generationConfig ?? {}) as Record<string, unknown>;\n      generationConfig.thinkingConfig = thinkingConfig;\n\n      if (typeof thinkingBudget === \"number\" && thinkingBudget > 0) {\n        ensureClaudeMaxOutputTokens(generationConfig, thinkingBudget);\n      }\n\n      payload.generationConfig = generationConfig;\n    }\n  }\n\n  // 3. Append interleaved thinking hint for thinking models with tools\n  if (isThinking && Array.isArray(payload.tools) && (payload.tools as unknown[]).length > 0) {\n    appendClaudeThinkingHint(payload);\n  }\n\n  // 4. Normalize tools\n  return normalizeClaudeTools(payload, cleanJSONSchema);\n}\n"
  },
  {
    "path": "src/plugin/transform/cross-model-sanitizer.test.ts",
    "content": "import { describe, it, expect } from \"vitest\";\nimport {\n  getModelFamily,\n  stripGeminiThinkingMetadata,\n  stripClaudeThinkingFields,\n  sanitizeCrossModelPayload,\n  deepSanitizeCrossModelMetadata,\n  sanitizeCrossModelPayloadInPlace,\n} from \"./cross-model-sanitizer\";\n\ndescribe(\"cross-model-sanitizer\", () => {\n  describe(\"getModelFamily\", () => {\n    it(\"identifies Claude models\", () => {\n      expect(getModelFamily(\"claude-opus-4-6-thinking-medium\")).toBe(\"claude\");\n      expect(getModelFamily(\"claude-sonnet-4-6\")).toBe(\"claude\");\n      expect(getModelFamily(\"claude-opus-4-6-thinking-low\")).toBe(\"claude\");\n    });\n\n    it(\"identifies Gemini models\", () => {\n      expect(getModelFamily(\"gemini-3-pro-low\")).toBe(\"gemini\");\n      expect(getModelFamily(\"gemini-3-flash\")).toBe(\"gemini\");\n      expect(getModelFamily(\"gemini-2.5-pro\")).toBe(\"gemini\");\n    });\n\n    it(\"returns unknown for unrecognized models\", () => {\n      expect(getModelFamily(\"gpt-4\")).toBe(\"unknown\");\n      expect(getModelFamily(\"unknown-model\")).toBe(\"unknown\");\n    });\n  });\n\n  describe(\"stripGeminiThinkingMetadata\", () => {\n    it(\"removes top-level thoughtSignature\", () => {\n      const part = {\n        thought: true,\n        text: \"thinking...\",\n        thoughtSignature: \"EsgQCsUQAXLI2ny...\",\n      };\n      const result = stripGeminiThinkingMetadata(part);\n      expect(result.part.thoughtSignature).toBeUndefined();\n      expect(result.stripped).toBe(1);\n      expect(result.part.text).toBe(\"thinking...\");\n    });\n\n    it(\"removes top-level thinkingMetadata\", () => {\n      const part = {\n        text: \"response\",\n        thinkingMetadata: { someData: true },\n      };\n      const result = stripGeminiThinkingMetadata(part);\n      expect(result.part.thinkingMetadata).toBeUndefined();\n      expect(result.stripped).toBe(1);\n    });\n\n    it(\"removes nested metadata.google.thoughtSignature\", () => {\n      const part = {\n        functionCall: { name: \"bash\", args: { command: \"df -h\" } },\n        metadata: {\n          google: {\n            thoughtSignature: \"EsgQCsUQAXLI2ny...\",\n          },\n        },\n      };\n      const result = stripGeminiThinkingMetadata(part);\n      const metadata = result.part.metadata as Record<string, unknown> | undefined;\n      const google = metadata?.google as Record<string, unknown> | undefined;\n      expect(google?.thoughtSignature).toBeUndefined();\n      expect(result.stripped).toBe(1);\n    });\n\n    it(\"preserves non-signature metadata when preserveNonSignature is true\", () => {\n      const part = {\n        functionCall: { name: \"bash\" },\n        metadata: {\n          google: {\n            thoughtSignature: \"sig123\",\n            groundingMetadata: \"preserved\",\n          },\n          cache_control: { type: \"ephemeral\" },\n        },\n      };\n      const result = stripGeminiThinkingMetadata(part, true);\n      const metadata = result.part.metadata as Record<string, unknown> | undefined;\n      const google = metadata?.google as Record<string, unknown> | undefined;\n      const cacheControl = metadata?.cache_control as Record<string, unknown> | undefined;\n      expect(google?.thoughtSignature).toBeUndefined();\n      expect(google?.groundingMetadata).toBe(\"preserved\");\n      expect(cacheControl?.type).toBe(\"ephemeral\");\n    });\n\n    it(\"cleans up empty google object\", () => {\n      const part = {\n        text: \"hello\",\n        metadata: {\n          google: {\n            thoughtSignature: \"sig123\",\n          },\n        },\n      };\n      const result = stripGeminiThinkingMetadata(part, true);\n      const metadata = result.part.metadata as Record<string, unknown> | undefined;\n      const google = metadata?.google as Record<string, unknown> | undefined;\n      expect(google).toBeUndefined();\n    });\n\n    it(\"cleans up empty metadata object\", () => {\n      const part = {\n        text: \"hello\",\n        metadata: {\n          google: {\n            thoughtSignature: \"sig123\",\n          },\n        },\n      };\n      const result = stripGeminiThinkingMetadata(part, true);\n      expect(result.part.metadata).toBeUndefined();\n    });\n\n    it(\"handles parts without metadata\", () => {\n      const part = { text: \"Hello\" };\n      const result = stripGeminiThinkingMetadata(part);\n      expect(result.part).toEqual({ text: \"Hello\" });\n      expect(result.stripped).toBe(0);\n    });\n  });\n\n  describe(\"stripClaudeThinkingFields\", () => {\n    it(\"removes signature from thinking blocks\", () => {\n      const part = {\n        type: \"thinking\",\n        thinking: \"Analyzing...\",\n        signature: \"claude-sig-abc123def456...\",\n      };\n      const result = stripClaudeThinkingFields(part);\n      expect(result.part.signature).toBeUndefined();\n      expect(result.stripped).toBe(1);\n      expect(result.part.thinking).toBe(\"Analyzing...\");\n    });\n\n    it(\"removes signature from redacted_thinking blocks\", () => {\n      const part = {\n        type: \"redacted_thinking\",\n        data: \"encrypted\",\n        signature: \"a]\".repeat(30),\n      };\n      const result = stripClaudeThinkingFields(part);\n      expect(result.part.signature).toBeUndefined();\n      expect(result.stripped).toBe(1);\n    });\n\n    it(\"removes long signature from non-thinking parts\", () => {\n      const part = {\n        type: \"text\",\n        text: \"hello\",\n        signature: \"a\".repeat(60),\n      };\n      const result = stripClaudeThinkingFields(part);\n      expect(result.part.signature).toBeUndefined();\n      expect(result.stripped).toBe(1);\n    });\n\n    it(\"preserves short signature-like fields\", () => {\n      const part = {\n        type: \"text\",\n        text: \"hello\",\n        signature: \"short\",\n      };\n      const result = stripClaudeThinkingFields(part);\n      expect(result.part.signature).toBe(\"short\");\n      expect(result.stripped).toBe(0);\n    });\n\n    it(\"handles parts without signature\", () => {\n      const part = { type: \"text\", text: \"Hello\" };\n      const result = stripClaudeThinkingFields(part);\n      expect(result.part).toEqual({ type: \"text\", text: \"Hello\" });\n      expect(result.stripped).toBe(0);\n    });\n  });\n\n  describe(\"deepSanitizeCrossModelMetadata\", () => {\n    it(\"sanitizes contents array (Gemini format)\", () => {\n      const payload = {\n        contents: [\n          {\n            role: \"model\",\n            parts: [\n              {\n                thought: true,\n                text: \"thinking...\",\n                thoughtSignature: \"sig1\",\n              },\n              {\n                functionCall: { name: \"bash\" },\n                metadata: { google: { thoughtSignature: \"sig2\" } },\n              },\n            ],\n          },\n        ],\n      };\n\n      const result = deepSanitizeCrossModelMetadata(payload, \"claude\");\n      const parts = (result.obj as any).contents[0].parts;\n\n      expect(parts[0].thoughtSignature).toBeUndefined();\n      expect(parts[1].metadata?.google?.thoughtSignature).toBeUndefined();\n      expect(parts[1].functionCall.name).toBe(\"bash\");\n      expect(result.stripped).toBe(2);\n    });\n\n    it(\"sanitizes messages array (Anthropic format)\", () => {\n      const payload = {\n        messages: [\n          {\n            role: \"assistant\",\n            content: [\n              {\n                type: \"thinking\",\n                thinking: \"analyzing...\",\n                signature: \"a\".repeat(60),\n              },\n              { type: \"tool_use\", id: \"tool_1\", name: \"bash\" },\n            ],\n          },\n        ],\n      };\n\n      const result = deepSanitizeCrossModelMetadata(payload, \"gemini\");\n      const content = (result.obj as any).messages[0].content;\n\n      expect(content[0].signature).toBeUndefined();\n      expect(content[1].name).toBe(\"bash\");\n      expect(result.stripped).toBe(1);\n    });\n\n    it(\"sanitizes extra_body.messages\", () => {\n      const payload = {\n        extra_body: {\n          messages: [\n            {\n              role: \"assistant\",\n              content: [\n                {\n                  type: \"tool_use\",\n                  metadata: { google: { thoughtSignature: \"sig\" } },\n                },\n              ],\n            },\n          ],\n        },\n      };\n\n      const result = deepSanitizeCrossModelMetadata(payload, \"claude\");\n      const content = (result.obj as any).extra_body.messages[0].content;\n\n      expect(content[0].metadata?.google?.thoughtSignature).toBeUndefined();\n      expect(result.stripped).toBe(1);\n    });\n\n    it(\"handles nested requests array (batch format)\", () => {\n      const payload = {\n        requests: [\n          {\n            contents: [\n              {\n                role: \"model\",\n                parts: [{ thoughtSignature: \"sig1\" }],\n              },\n            ],\n          },\n          {\n            contents: [\n              {\n                role: \"model\",\n                parts: [{ thoughtSignature: \"sig2\" }],\n              },\n            ],\n          },\n        ],\n      };\n\n      const result = deepSanitizeCrossModelMetadata(payload, \"claude\");\n      expect(result.stripped).toBe(2);\n    });\n  });\n\n  describe(\"sanitizeCrossModelPayload\", () => {\n    it(\"strips Gemini signatures when target is Claude\", () => {\n      const payload = {\n        contents: [\n          {\n            role: \"model\",\n            parts: [\n              { thought: true, text: \"thinking...\", thoughtSignature: \"sig1\" },\n              {\n                functionCall: { name: \"bash\" },\n                metadata: { google: { thoughtSignature: \"sig2\" } },\n              },\n            ],\n          },\n        ],\n      };\n\n      const result = sanitizeCrossModelPayload(payload, {\n        targetModel: \"claude-opus-4-6-thinking-medium\",\n      });\n\n      expect(result.modified).toBe(true);\n      expect(result.signaturesStripped).toBe(2);\n      const parts = (result.payload as any).contents[0].parts;\n      expect(parts[0].thoughtSignature).toBeUndefined();\n      expect(parts[1].metadata?.google?.thoughtSignature).toBeUndefined();\n    });\n\n    it(\"strips Claude signatures when target is Gemini\", () => {\n      const payload = {\n        messages: [\n          {\n            role: \"assistant\",\n            content: [\n              {\n                type: \"thinking\",\n                thinking: \"analyzing...\",\n                signature: \"a\".repeat(60),\n              },\n            ],\n          },\n        ],\n      };\n\n      const result = sanitizeCrossModelPayload(payload, {\n        targetModel: \"gemini-3-pro-low\",\n      });\n\n      expect(result.modified).toBe(true);\n      expect(result.signaturesStripped).toBe(1);\n    });\n\n    it(\"skips sanitization for unknown target model\", () => {\n      const payload = {\n        contents: [\n          {\n            parts: [{ thoughtSignature: \"sig\" }],\n          },\n        ],\n      };\n\n      const result = sanitizeCrossModelPayload(payload, {\n        targetModel: \"gpt-4\",\n      });\n\n      expect(result.modified).toBe(false);\n      expect(result.signaturesStripped).toBe(0);\n      expect((result.payload as any).contents[0].parts[0].thoughtSignature).toBe(\"sig\");\n    });\n\n    it(\"preserves functionCall structure\", () => {\n      const payload = {\n        contents: [\n          {\n            role: \"model\",\n            parts: [\n              {\n                functionCall: {\n                  name: \"Bash\",\n                  args: { command: \"df -h\", description: \"Check disk space\" },\n                },\n                metadata: { google: { thoughtSignature: \"sig\" } },\n              },\n            ],\n          },\n        ],\n      };\n\n      const result = sanitizeCrossModelPayload(payload, {\n        targetModel: \"claude-opus-4-6-thinking-low\",\n      });\n\n      const fc = (result.payload as any).contents[0].parts[0].functionCall;\n      expect(fc.name).toBe(\"Bash\");\n      expect(fc.args.command).toBe(\"df -h\");\n    });\n\n    it(\"preserves non-signature metadata when option is true\", () => {\n      const payload = {\n        contents: [\n          {\n            parts: [\n              {\n                functionCall: { name: \"read\" },\n                metadata: {\n                  google: {\n                    thoughtSignature: \"strip-me\",\n                    groundingMetadata: \"keep-me\",\n                  },\n                  cache_control: { type: \"ephemeral\" },\n                },\n              },\n            ],\n          },\n        ],\n      };\n\n      const result = sanitizeCrossModelPayload(payload, {\n        targetModel: \"claude-sonnet-4\",\n        preserveNonSignatureMetadata: true,\n      });\n\n      const meta = (result.payload as any).contents[0].parts[0].metadata;\n      expect(meta.google.thoughtSignature).toBeUndefined();\n      expect(meta.google.groundingMetadata).toBe(\"keep-me\");\n      expect(meta.cache_control.type).toBe(\"ephemeral\");\n    });\n  });\n\n  describe(\"sanitizeCrossModelPayloadInPlace\", () => {\n    it(\"mutates payload directly\", () => {\n      const payload = {\n        contents: [\n          {\n            parts: [\n              {\n                thought: true,\n                thoughtSignature: \"sig\",\n              },\n            ],\n          },\n        ],\n      };\n\n      const stripped = sanitizeCrossModelPayloadInPlace(\n        payload as Record<string, unknown>,\n        { targetModel: \"claude-opus-4-6-thinking-high\" }\n      );\n\n      expect(stripped).toBe(1);\n      expect((payload as any).contents[0].parts[0].thoughtSignature).toBeUndefined();\n    });\n\n    it(\"handles extra_body.messages\", () => {\n      const payload = {\n        extra_body: {\n          messages: [\n            {\n              content: [{ metadata: { google: { thoughtSignature: \"sig\" } } }],\n            },\n          ],\n        },\n      };\n\n      const stripped = sanitizeCrossModelPayloadInPlace(\n        payload as Record<string, unknown>,\n        { targetModel: \"claude-sonnet-4\" }\n      );\n\n      expect(stripped).toBe(1);\n    });\n  });\n\n  describe(\"real-world reproduction scenario\", () => {\n    it(\"handles Gemini thinking + tool call -> Claude tool call scenario\", () => {\n      const geminiSessionHistory = {\n        contents: [\n          {\n            role: \"user\",\n            parts: [\n              {\n                text: \"Check disk space. Think about which filesystems are most utilized.\",\n              },\n            ],\n          },\n          {\n            role: \"model\",\n            parts: [\n              {\n                thought: true,\n                text: \"I need to analyze disk usage by running df -h...\",\n                thoughtSignature:\n                  \"EsgQCsUQAXLI2nybuafAE150LGTo2r78fakesig123\",\n              },\n              {\n                functionCall: {\n                  name: \"Bash\",\n                  args: { command: \"df -h\", description: \"Check disk space\" },\n                },\n                metadata: {\n                  google: {\n                    thoughtSignature:\n                      \"EsgQCsUQAXLI2nybuafAE150LGTo2r78fakesig123\",\n                  },\n                },\n              },\n            ],\n          },\n          {\n            role: \"function\",\n            parts: [\n              {\n                functionResponse: {\n                  name: \"Bash\",\n                  response: {\n                    output: \"Filesystem      Size  Used Avail Use%...\",\n                  },\n                },\n              },\n            ],\n          },\n          {\n            role: \"model\",\n            parts: [{ text: \"The root filesystem is 62% utilized...\" }],\n          },\n          {\n            role: \"user\",\n            parts: [{ text: \"Now check memory usage with free -h\" }],\n          },\n        ],\n      };\n\n      const result = sanitizeCrossModelPayload(geminiSessionHistory, {\n        targetModel: \"claude-opus-4-6-thinking-medium\",\n      });\n\n      expect(result.modified).toBe(true);\n      expect(result.signaturesStripped).toBe(2);\n\n      const modelParts = (result.payload as any).contents[1].parts;\n      expect(modelParts[0].thoughtSignature).toBeUndefined();\n      expect(modelParts[0].thought).toBe(true);\n      expect(modelParts[0].text).toContain(\"analyze disk usage\");\n\n      expect(modelParts[1].metadata?.google?.thoughtSignature).toBeUndefined();\n      expect(modelParts[1].functionCall.name).toBe(\"Bash\");\n      expect(modelParts[1].functionCall.args.command).toBe(\"df -h\");\n\n      const functionResponse = (result.payload as any).contents[2].parts[0]\n        .functionResponse;\n      expect(functionResponse.name).toBe(\"Bash\");\n    });\n\n    it(\"handles Claude thinking + tool use -> Gemini tool call scenario\", () => {\n      const claudeSessionHistory = {\n        messages: [\n          {\n            role: \"user\",\n            content: [{ type: \"text\", text: \"List files\" }],\n          },\n          {\n            role: \"assistant\",\n            content: [\n              {\n                type: \"thinking\",\n                thinking: \"I should list the files...\",\n                signature: \"a\".repeat(100),\n              },\n              {\n                type: \"tool_use\",\n                id: \"tool_abc123\",\n                name: \"bash\",\n                input: { command: \"ls -la\" },\n              },\n            ],\n          },\n          {\n            role: \"user\",\n            content: [\n              {\n                type: \"tool_result\",\n                tool_use_id: \"tool_abc123\",\n                content: \"file1.txt\\nfile2.txt\",\n              },\n            ],\n          },\n        ],\n      };\n\n      const result = sanitizeCrossModelPayload(claudeSessionHistory, {\n        targetModel: \"gemini-3-flash\",\n      });\n\n      expect(result.modified).toBe(true);\n      expect(result.signaturesStripped).toBe(1);\n\n      const assistantContent = (result.payload as any).messages[1].content;\n      expect(assistantContent[0].signature).toBeUndefined();\n      expect(assistantContent[0].thinking).toContain(\"list the files\");\n      expect(assistantContent[1].name).toBe(\"bash\");\n    });\n  });\n});\n"
  },
  {
    "path": "src/plugin/transform/cross-model-sanitizer.ts",
    "content": "/**\n * Cross-Model Metadata Sanitization\n *\n * Fixes: \"Invalid `signature` in `thinking` block\" error when switching models mid-session.\n * \n * Root cause: Gemini stores thoughtSignature in metadata.google, Claude stores signature\n * in top-level thinking blocks. Foreign signatures fail validation on the target model.\n */\n\nimport { isClaudeModel } from \"./claude\";\nimport { isGeminiModel } from \"./gemini\";\n\nexport type ModelFamily = \"claude\" | \"gemini\" | \"unknown\";\n\nexport interface SanitizerOptions {\n  targetModel: string;\n  sourceModel?: string;\n  preserveNonSignatureMetadata?: boolean;\n}\n\nexport interface SanitizationResult {\n  payload: unknown;\n  modified: boolean;\n  signaturesStripped: number;\n}\n\nconst GEMINI_SIGNATURE_FIELDS = [\"thoughtSignature\", \"thinkingMetadata\"] as const;\nconst CLAUDE_SIGNATURE_FIELDS = [\"signature\"] as const;\n\nexport function getModelFamily(model: string): ModelFamily {\n  if (isClaudeModel(model)) return \"claude\";\n  if (isGeminiModel(model)) return \"gemini\";\n  return \"unknown\";\n}\n\nfunction isPlainObject(value: unknown): value is Record<string, unknown> {\n  return typeof value === \"object\" && value !== null && !Array.isArray(value);\n}\n\nexport function stripGeminiThinkingMetadata(\n  part: Record<string, unknown>,\n  preserveNonSignature = true\n): { part: Record<string, unknown>; stripped: number } {\n  let stripped = 0;\n\n  if (\"thoughtSignature\" in part) {\n    delete part.thoughtSignature;\n    stripped++;\n  }\n\n  if (\"thinkingMetadata\" in part) {\n    delete part.thinkingMetadata;\n    stripped++;\n  }\n\n  if (isPlainObject(part.metadata)) {\n    const metadata = part.metadata as Record<string, unknown>;\n    if (isPlainObject(metadata.google)) {\n      const google = metadata.google as Record<string, unknown>;\n\n      for (const field of GEMINI_SIGNATURE_FIELDS) {\n        if (field in google) {\n          delete google[field];\n          stripped++;\n        }\n      }\n\n      if (!preserveNonSignature || Object.keys(google).length === 0) {\n        delete metadata.google;\n      }\n\n      if (Object.keys(metadata).length === 0) {\n        delete part.metadata;\n      }\n    }\n  }\n\n  return { part, stripped };\n}\n\nexport function stripClaudeThinkingFields(\n  part: Record<string, unknown>\n): { part: Record<string, unknown>; stripped: number } {\n  let stripped = 0;\n\n  if (part.type === \"thinking\" || part.type === \"redacted_thinking\") {\n    for (const field of CLAUDE_SIGNATURE_FIELDS) {\n      if (field in part) {\n        delete part[field];\n        stripped++;\n      }\n    }\n  }\n\n  if (\"signature\" in part && typeof part.signature === \"string\") {\n    if ((part.signature as string).length >= 50) {\n      delete part.signature;\n      stripped++;\n    }\n  }\n\n  return { part, stripped };\n}\n\nfunction sanitizePart(\n  part: unknown,\n  targetFamily: ModelFamily,\n  preserveNonSignature: boolean\n): { part: unknown; stripped: number } {\n  if (!isPlainObject(part)) {\n    return { part, stripped: 0 };\n  }\n\n  let totalStripped = 0;\n  const partObj = { ...part } as Record<string, unknown>;\n\n  if (targetFamily === \"claude\") {\n    const result = stripGeminiThinkingMetadata(partObj, preserveNonSignature);\n    totalStripped += result.stripped;\n  } else if (targetFamily === \"gemini\") {\n    const result = stripClaudeThinkingFields(partObj);\n    totalStripped += result.stripped;\n  }\n\n  return { part: partObj, stripped: totalStripped };\n}\n\nfunction sanitizeParts(\n  parts: unknown[],\n  targetFamily: ModelFamily,\n  preserveNonSignature: boolean\n): { parts: unknown[]; stripped: number } {\n  let totalStripped = 0;\n\n  const sanitizedParts = parts.map((part) => {\n    const result = sanitizePart(part, targetFamily, preserveNonSignature);\n    totalStripped += result.stripped;\n    return result.part;\n  });\n\n  return { parts: sanitizedParts, stripped: totalStripped };\n}\n\nfunction sanitizeContents(\n  contents: unknown[],\n  targetFamily: ModelFamily,\n  preserveNonSignature: boolean\n): { contents: unknown[]; stripped: number } {\n  let totalStripped = 0;\n\n  const sanitizedContents = contents.map((content) => {\n    if (!isPlainObject(content)) return content;\n\n    const contentObj = { ...content } as Record<string, unknown>;\n\n    if (Array.isArray(contentObj.parts)) {\n      const result = sanitizeParts(\n        contentObj.parts,\n        targetFamily,\n        preserveNonSignature\n      );\n      contentObj.parts = result.parts;\n      totalStripped += result.stripped;\n    }\n\n    return contentObj;\n  });\n\n  return { contents: sanitizedContents, stripped: totalStripped };\n}\n\nfunction sanitizeMessages(\n  messages: unknown[],\n  targetFamily: ModelFamily,\n  preserveNonSignature: boolean\n): { messages: unknown[]; stripped: number } {\n  let totalStripped = 0;\n\n  const sanitizedMessages = messages.map((message) => {\n    if (!isPlainObject(message)) return message;\n\n    const messageObj = { ...message } as Record<string, unknown>;\n\n    if (Array.isArray(messageObj.content)) {\n      const result = sanitizeParts(\n        messageObj.content,\n        targetFamily,\n        preserveNonSignature\n      );\n      messageObj.content = result.parts;\n      totalStripped += result.stripped;\n    }\n\n    return messageObj;\n  });\n\n  return { messages: sanitizedMessages, stripped: totalStripped };\n}\n\nexport function deepSanitizeCrossModelMetadata(\n  obj: unknown,\n  targetFamily: ModelFamily,\n  preserveNonSignature = true\n): { obj: unknown; stripped: number } {\n  if (!isPlainObject(obj)) {\n    return { obj, stripped: 0 };\n  }\n\n  let totalStripped = 0;\n  const result = { ...obj } as Record<string, unknown>;\n\n  if (Array.isArray(result.contents)) {\n    const sanitized = sanitizeContents(\n      result.contents,\n      targetFamily,\n      preserveNonSignature\n    );\n    result.contents = sanitized.contents;\n    totalStripped += sanitized.stripped;\n  }\n\n  if (Array.isArray(result.messages)) {\n    const sanitized = sanitizeMessages(\n      result.messages,\n      targetFamily,\n      preserveNonSignature\n    );\n    result.messages = sanitized.messages;\n    totalStripped += sanitized.stripped;\n  }\n\n  if (isPlainObject(result.extra_body)) {\n    const extraBody = { ...result.extra_body } as Record<string, unknown>;\n    if (Array.isArray(extraBody.messages)) {\n      const sanitized = sanitizeMessages(\n        extraBody.messages,\n        targetFamily,\n        preserveNonSignature\n      );\n      extraBody.messages = sanitized.messages;\n      totalStripped += sanitized.stripped;\n    }\n    result.extra_body = extraBody;\n  }\n\n  if (Array.isArray(result.requests)) {\n    const sanitizedRequests = result.requests.map((req) => {\n      const sanitized = deepSanitizeCrossModelMetadata(\n        req,\n        targetFamily,\n        preserveNonSignature\n      );\n      totalStripped += sanitized.stripped;\n      return sanitized.obj;\n    });\n    result.requests = sanitizedRequests;\n  }\n\n  return { obj: result, stripped: totalStripped };\n}\n\nexport function sanitizeCrossModelPayload(\n  payload: unknown,\n  options: SanitizerOptions\n): SanitizationResult {\n  const targetFamily = getModelFamily(options.targetModel);\n\n  if (targetFamily === \"unknown\") {\n    return {\n      payload,\n      modified: false,\n      signaturesStripped: 0,\n    };\n  }\n\n  const preserveNonSignature = options.preserveNonSignatureMetadata ?? true;\n  const result = deepSanitizeCrossModelMetadata(\n    payload,\n    targetFamily,\n    preserveNonSignature\n  );\n\n  return {\n    payload: result.obj,\n    modified: result.stripped > 0,\n    signaturesStripped: result.stripped,\n  };\n}\n\nexport function sanitizeCrossModelPayloadInPlace(\n  payload: Record<string, unknown>,\n  options: SanitizerOptions\n): number {\n  const targetFamily = getModelFamily(options.targetModel);\n\n  if (targetFamily === \"unknown\") {\n    return 0;\n  }\n\n  const preserveNonSignature = options.preserveNonSignatureMetadata ?? true;\n  let totalStripped = 0;\n\n  const sanitizePartsInPlace = (parts: unknown[]): void => {\n    for (const part of parts) {\n      if (!isPlainObject(part)) continue;\n\n      if (targetFamily === \"claude\") {\n        const result = stripGeminiThinkingMetadata(\n          part as Record<string, unknown>,\n          preserveNonSignature\n        );\n        totalStripped += result.stripped;\n      } else if (targetFamily === \"gemini\") {\n        const result = stripClaudeThinkingFields(\n          part as Record<string, unknown>\n        );\n        totalStripped += result.stripped;\n      }\n    }\n  };\n\n  if (Array.isArray(payload.contents)) {\n    for (const content of payload.contents) {\n      if (isPlainObject(content) && Array.isArray(content.parts)) {\n        sanitizePartsInPlace(content.parts);\n      }\n    }\n  }\n\n  if (Array.isArray(payload.messages)) {\n    for (const message of payload.messages) {\n      if (isPlainObject(message) && Array.isArray(message.content)) {\n        sanitizePartsInPlace(message.content);\n      }\n    }\n  }\n\n  if (isPlainObject(payload.extra_body)) {\n    const extraBody = payload.extra_body as Record<string, unknown>;\n    if (Array.isArray(extraBody.messages)) {\n      for (const message of extraBody.messages) {\n        if (isPlainObject(message) && Array.isArray(message.content)) {\n          sanitizePartsInPlace(message.content);\n        }\n      }\n    }\n  }\n\n  return totalStripped;\n}\n"
  },
  {
    "path": "src/plugin/transform/gemini.test.ts",
    "content": "import { describe, it, expect, beforeEach, afterEach, vi } from \"vitest\";\nimport {\n  isGeminiModel,\n  isGemini3Model,\n  isGemini25Model,\n  isImageGenerationModel,\n  buildGemini3ThinkingConfig,\n  buildGemini25ThinkingConfig,\n  buildImageGenerationConfig,\n  normalizeGeminiTools,\n  applyGeminiTransforms,\n  toGeminiSchema,\n  wrapToolsAsFunctionDeclarations,\n} from \"./gemini\";\nimport type { RequestPayload } from \"./types\";\n\ndescribe(\"transform/gemini\", () => {\n  describe(\"isGeminiModel\", () => {\n    it(\"returns true for gemini-pro\", () => {\n      expect(isGeminiModel(\"gemini-pro\")).toBe(true);\n    });\n\n    it(\"returns true for gemini-1.5-pro\", () => {\n      expect(isGeminiModel(\"gemini-1.5-pro\")).toBe(true);\n    });\n\n    it(\"returns true for gemini-2.5-flash\", () => {\n      expect(isGeminiModel(\"gemini-2.5-flash\")).toBe(true);\n    });\n\n    it(\"returns true for gemini-3-pro-high\", () => {\n      expect(isGeminiModel(\"gemini-3-pro-high\")).toBe(true);\n    });\n\n    it(\"returns true for uppercase GEMINI-PRO\", () => {\n      expect(isGeminiModel(\"GEMINI-PRO\")).toBe(true);\n    });\n\n    it(\"returns true for mixed case Gemini-Pro\", () => {\n      expect(isGeminiModel(\"Gemini-Pro\")).toBe(true);\n    });\n\n    it(\"returns false for claude-3-opus\", () => {\n      expect(isGeminiModel(\"claude-3-opus\")).toBe(false);\n    });\n\n    it(\"returns false for gpt-4\", () => {\n      expect(isGeminiModel(\"gpt-4\")).toBe(false);\n    });\n\n    it(\"returns false for gemini-claude hybrid (contains both)\", () => {\n      expect(isGeminiModel(\"gemini-claude-hybrid\")).toBe(false);\n    });\n\n    it(\"returns false for claude-on-gemini\", () => {\n      expect(isGeminiModel(\"claude-on-gemini\")).toBe(false);\n    });\n\n    it(\"returns false for empty string\", () => {\n      expect(isGeminiModel(\"\")).toBe(false);\n    });\n  });\n\n  describe(\"isGemini3Model\", () => {\n    it(\"returns true for gemini-3-pro\", () => {\n      expect(isGemini3Model(\"gemini-3-pro\")).toBe(true);\n    });\n\n    it(\"returns true for gemini-3-pro-high\", () => {\n      expect(isGemini3Model(\"gemini-3-pro-high\")).toBe(true);\n    });\n\n    it(\"returns true for gemini-3-flash\", () => {\n      expect(isGemini3Model(\"gemini-3-flash\")).toBe(true);\n    });\n\n    it(\"returns true for gemini-3.1-pro\", () => {\n      expect(isGemini3Model(\"gemini-3.1-pro\")).toBe(true);\n    });\n\n    it(\"returns true for uppercase GEMINI-3-PRO\", () => {\n      expect(isGemini3Model(\"GEMINI-3-PRO\")).toBe(true);\n    });\n\n    it(\"returns false for gemini-2.5-pro\", () => {\n      expect(isGemini3Model(\"gemini-2.5-pro\")).toBe(false);\n    });\n\n    it(\"returns false for gemini-pro\", () => {\n      expect(isGemini3Model(\"gemini-pro\")).toBe(false);\n    });\n\n    it(\"returns false for claude-3-opus\", () => {\n      expect(isGemini3Model(\"claude-3-opus\")).toBe(false);\n    });\n\n    it(\"returns false for empty string\", () => {\n      expect(isGemini3Model(\"\")).toBe(false);\n    });\n  });\n\n  describe(\"isGemini25Model\", () => {\n    it(\"returns true for gemini-2.5-pro\", () => {\n      expect(isGemini25Model(\"gemini-2.5-pro\")).toBe(true);\n    });\n\n    it(\"returns true for gemini-2.5-flash\", () => {\n      expect(isGemini25Model(\"gemini-2.5-flash\")).toBe(true);\n    });\n\n    it(\"returns true for gemini-2.5-pro-preview\", () => {\n      expect(isGemini25Model(\"gemini-2.5-pro-preview\")).toBe(true);\n    });\n\n    it(\"returns true for uppercase GEMINI-2.5-PRO\", () => {\n      expect(isGemini25Model(\"GEMINI-2.5-PRO\")).toBe(true);\n    });\n\n    it(\"returns false for gemini-3-pro\", () => {\n      expect(isGemini25Model(\"gemini-3-pro\")).toBe(false);\n    });\n\n    it(\"returns false for gemini-2.0-flash\", () => {\n      expect(isGemini25Model(\"gemini-2.0-flash\")).toBe(false);\n    });\n\n    it(\"returns false for gemini-pro\", () => {\n      expect(isGemini25Model(\"gemini-pro\")).toBe(false);\n    });\n\n    it(\"returns false for empty string\", () => {\n      expect(isGemini25Model(\"\")).toBe(false);\n    });\n  });\n\n  describe(\"buildGemini3ThinkingConfig\", () => {\n    it(\"builds config with includeThoughts true and low tier\", () => {\n      const config = buildGemini3ThinkingConfig(true, \"low\");\n      expect(config).toEqual({\n        includeThoughts: true,\n        thinkingLevel: \"low\",\n      });\n    });\n\n    it(\"builds config with includeThoughts true and medium tier\", () => {\n      const config = buildGemini3ThinkingConfig(true, \"medium\");\n      expect(config).toEqual({\n        includeThoughts: true,\n        thinkingLevel: \"medium\",\n      });\n    });\n\n    it(\"builds config with includeThoughts true and high tier\", () => {\n      const config = buildGemini3ThinkingConfig(true, \"high\");\n      expect(config).toEqual({\n        includeThoughts: true,\n        thinkingLevel: \"high\",\n      });\n    });\n\n    it(\"builds config with includeThoughts false\", () => {\n      const config = buildGemini3ThinkingConfig(false, \"high\");\n      expect(config).toEqual({\n        includeThoughts: false,\n        thinkingLevel: \"high\",\n      });\n    });\n  });\n\n  describe(\"buildGemini25ThinkingConfig\", () => {\n    it(\"builds config with includeThoughts true and budget\", () => {\n      const config = buildGemini25ThinkingConfig(true, 8192);\n      expect(config).toEqual({\n        includeThoughts: true,\n        thinkingBudget: 8192,\n      });\n    });\n\n    it(\"builds config with includeThoughts false and budget\", () => {\n      const config = buildGemini25ThinkingConfig(false, 16384);\n      expect(config).toEqual({\n        includeThoughts: false,\n        thinkingBudget: 16384,\n      });\n    });\n\n    it(\"builds config without budget when undefined\", () => {\n      const config = buildGemini25ThinkingConfig(true, undefined);\n      expect(config).toEqual({\n        includeThoughts: true,\n      });\n      expect(config).not.toHaveProperty(\"thinkingBudget\");\n    });\n\n    it(\"builds config without budget when zero\", () => {\n      const config = buildGemini25ThinkingConfig(true, 0);\n      expect(config).toEqual({\n        includeThoughts: true,\n      });\n      expect(config).not.toHaveProperty(\"thinkingBudget\");\n    });\n\n    it(\"builds config without budget when negative\", () => {\n      const config = buildGemini25ThinkingConfig(true, -1000);\n      expect(config).toEqual({\n        includeThoughts: true,\n      });\n      expect(config).not.toHaveProperty(\"thinkingBudget\");\n    });\n\n    it(\"builds config with large budget\", () => {\n      const config = buildGemini25ThinkingConfig(true, 100000);\n      expect(config).toEqual({\n        includeThoughts: true,\n        thinkingBudget: 100000,\n      });\n    });\n  });\n\n  describe(\"normalizeGeminiTools\", () => {\n    it(\"returns empty debug info when tools is not an array\", () => {\n      const payload: RequestPayload = { contents: [] };\n      const result = normalizeGeminiTools(payload);\n      expect(result).toEqual({\n        toolDebugMissing: 0,\n        toolDebugSummaries: [],\n      });\n    });\n\n    it(\"returns empty debug info when tools is undefined\", () => {\n      const payload: RequestPayload = { contents: [], tools: undefined };\n      const result = normalizeGeminiTools(payload);\n      expect(result).toEqual({\n        toolDebugMissing: 0,\n        toolDebugSummaries: [],\n      });\n    });\n\n    it(\"normalizes tool with function.input_schema\", () => {\n      const payload: RequestPayload = {\n        contents: [],\n        tools: [\n          {\n            function: {\n              name: \"test_tool\",\n              description: \"A test tool\",\n              input_schema: { type: \"object\", properties: { foo: { type: \"string\" } } },\n            },\n          },\n        ],\n      };\n      const result = normalizeGeminiTools(payload);\n      expect(result.toolDebugMissing).toBe(0);\n      expect(result.toolDebugSummaries).toHaveLength(1);\n      expect((payload.tools as unknown[])[0]).not.toHaveProperty(\"custom\");\n    });\n\n    it(\"normalizes tool with function.parameters\", () => {\n      const payload: RequestPayload = {\n        contents: [],\n        tools: [\n          {\n            function: {\n              name: \"test_tool\",\n              description: \"A test tool\",\n              parameters: { type: \"object\", properties: { bar: { type: \"number\" } } },\n            },\n          },\n        ],\n      };\n      const result = normalizeGeminiTools(payload);\n      expect(result.toolDebugMissing).toBe(0);\n    });\n\n    it(\"creates custom from function and strips it for Gemini\", () => {\n      const payload: RequestPayload = {\n        contents: [],\n        tools: [\n          {\n            function: {\n              name: \"my_func\",\n              description: \"My function\",\n              input_schema: { type: \"object\" },\n            },\n          },\n        ],\n      };\n      normalizeGeminiTools(payload);\n      expect((payload.tools as unknown[])[0]).not.toHaveProperty(\"custom\");\n      expect((payload.tools as unknown[])[0]).toHaveProperty(\"function\");\n    });\n\n    it(\"creates custom when both function and custom are missing\", () => {\n      const payload: RequestPayload = {\n        contents: [],\n        tools: [\n          {\n            name: \"standalone_tool\",\n            description: \"A standalone tool\",\n            parameters: { type: \"object\", properties: {} },\n          },\n        ],\n      };\n      normalizeGeminiTools(payload);\n      expect((payload.tools as unknown[])[0]).not.toHaveProperty(\"custom\");\n    });\n\n    it(\"counts missing schemas\", () => {\n      const payload: RequestPayload = {\n        contents: [],\n        tools: [\n          { name: \"tool1\" },\n          { name: \"tool2\" },\n          { function: { name: \"tool3\", input_schema: { type: \"object\" } } },\n        ],\n      };\n      const result = normalizeGeminiTools(payload);\n      expect(result.toolDebugMissing).toBe(2);\n    });\n\n    it(\"generates debug summaries for each tool\", () => {\n      const payload: RequestPayload = {\n        contents: [],\n        tools: [\n          { function: { name: \"t1\", input_schema: { type: \"object\" } } },\n          { function: { name: \"t2\", input_schema: { type: \"object\" } } },\n        ],\n      };\n      const result = normalizeGeminiTools(payload);\n      expect(result.toolDebugSummaries).toHaveLength(2);\n      expect(result.toolDebugSummaries[0]).toContain(\"idx=0\");\n      expect(result.toolDebugSummaries[1]).toContain(\"idx=1\");\n    });\n\n    it(\"uses default tool name when name is missing\", () => {\n      const payload: RequestPayload = {\n        contents: [],\n        tools: [{}],\n      };\n      const result = normalizeGeminiTools(payload);\n      expect(result.toolDebugSummaries[0]).toContain(\"idx=0\");\n    });\n\n    it(\"extracts schema from custom.input_schema\", () => {\n      const payload: RequestPayload = {\n        contents: [],\n        tools: [\n          {\n            custom: {\n              name: \"custom_tool\",\n              input_schema: { type: \"object\", properties: { x: { type: \"string\" } } },\n            },\n          },\n        ],\n      };\n      normalizeGeminiTools(payload);\n      expect((payload.tools as unknown[])[0]).not.toHaveProperty(\"custom\");\n    });\n\n    it(\"extracts schema from inputSchema (camelCase)\", () => {\n      const payload: RequestPayload = {\n        contents: [],\n        tools: [\n          {\n            name: \"camel_tool\",\n            inputSchema: { type: \"object\", properties: { y: { type: \"boolean\" } } },\n          },\n        ],\n      };\n      normalizeGeminiTools(payload);\n      expect((payload.tools as unknown[])[0]).not.toHaveProperty(\"custom\");\n    });\n  });\n\n  describe(\"applyGeminiTransforms\", () => {\n    it(\"applies Gemini 3 thinking config with thinkingLevel\", () => {\n      const payload: RequestPayload = { contents: [] };\n      applyGeminiTransforms(payload, {\n        model: \"gemini-3-pro-high\",\n        tierThinkingLevel: \"high\",\n        normalizedThinking: { includeThoughts: true },\n      });\n      const genConfig = payload.generationConfig as Record<string, unknown>;\n      expect(genConfig.thinkingConfig).toEqual({\n        includeThoughts: true,\n        thinkingLevel: \"high\",\n      });\n    });\n\n    it(\"applies Gemini 2.5 thinking config with thinkingBudget\", () => {\n      const payload: RequestPayload = { contents: [] };\n      applyGeminiTransforms(payload, {\n        model: \"gemini-2.5-flash\",\n        tierThinkingBudget: 8192,\n        normalizedThinking: { includeThoughts: true },\n      });\n      const genConfig = payload.generationConfig as Record<string, unknown>;\n      expect(genConfig.thinkingConfig).toEqual({\n        includeThoughts: true,\n        thinkingBudget: 8192,\n      });\n    });\n\n    it(\"prefers tierThinkingBudget over normalizedThinking.thinkingBudget\", () => {\n      const payload: RequestPayload = { contents: [] };\n      applyGeminiTransforms(payload, {\n        model: \"gemini-2.5-pro\",\n        tierThinkingBudget: 16384,\n        normalizedThinking: { includeThoughts: true, thinkingBudget: 8192 },\n      });\n      const genConfig = payload.generationConfig as Record<string, unknown>;\n      expect((genConfig.thinkingConfig as Record<string, unknown>).thinkingBudget).toBe(16384);\n    });\n\n    it(\"falls back to normalizedThinking.thinkingBudget when tierThinkingBudget is undefined\", () => {\n      const payload: RequestPayload = { contents: [] };\n      applyGeminiTransforms(payload, {\n        model: \"gemini-2.5-pro\",\n        normalizedThinking: { includeThoughts: true, thinkingBudget: 4096 },\n      });\n      const genConfig = payload.generationConfig as Record<string, unknown>;\n      expect((genConfig.thinkingConfig as Record<string, unknown>).thinkingBudget).toBe(4096);\n    });\n\n    it(\"does not apply thinking config when normalizedThinking is undefined\", () => {\n      const payload: RequestPayload = { contents: [] };\n      applyGeminiTransforms(payload, {\n        model: \"gemini-3-pro\",\n      });\n      expect(payload.generationConfig).toBeUndefined();\n    });\n\n    it(\"preserves existing generationConfig properties\", () => {\n      const payload: RequestPayload = {\n        contents: [],\n        generationConfig: { temperature: 0.7, maxOutputTokens: 1000 },\n      };\n      applyGeminiTransforms(payload, {\n        model: \"gemini-3-pro-medium\",\n        tierThinkingLevel: \"medium\",\n        normalizedThinking: { includeThoughts: true },\n      });\n      const genConfig = payload.generationConfig as Record<string, unknown>;\n      expect(genConfig.temperature).toBe(0.7);\n      expect(genConfig.maxOutputTokens).toBe(1000);\n      expect(genConfig.thinkingConfig).toBeDefined();\n    });\n\n    it(\"normalizes tools and returns debug info\", () => {\n      const payload: RequestPayload = {\n        contents: [],\n        tools: [\n          { function: { name: \"tool1\", input_schema: { type: \"object\" } } },\n          { name: \"tool2\" },\n        ],\n      };\n      const result = applyGeminiTransforms(payload, {\n        model: \"gemini-2.5-flash\",\n      });\n      expect(result.toolDebugSummaries).toHaveLength(2);\n      expect(result.toolDebugMissing).toBe(1);\n    });\n\n    it(\"defaults includeThoughts to true when not specified\", () => {\n      const payload: RequestPayload = { contents: [] };\n      applyGeminiTransforms(payload, {\n        model: \"gemini-3-pro-low\",\n        tierThinkingLevel: \"low\",\n        normalizedThinking: {},\n      });\n      const genConfig = payload.generationConfig as Record<string, unknown>;\n      expect((genConfig.thinkingConfig as Record<string, unknown>).includeThoughts).toBe(true);\n    });\n\n    it(\"respects includeThoughts false\", () => {\n      const payload: RequestPayload = { contents: [] };\n      applyGeminiTransforms(payload, {\n        model: \"gemini-3-pro-high\",\n        tierThinkingLevel: \"high\",\n        normalizedThinking: { includeThoughts: false },\n      });\n      const genConfig = payload.generationConfig as Record<string, unknown>;\n      expect((genConfig.thinkingConfig as Record<string, unknown>).includeThoughts).toBe(false);\n    });\n\n    it(\"handles Gemini 2.5 without tierThinkingBudget or normalizedThinking.thinkingBudget\", () => {\n      const payload: RequestPayload = { contents: [] };\n      applyGeminiTransforms(payload, {\n        model: \"gemini-2.5-pro\",\n        normalizedThinking: { includeThoughts: true },\n      });\n      const genConfig = payload.generationConfig as Record<string, unknown>;\n      const thinkingConfig = genConfig.thinkingConfig as Record<string, unknown>;\n      expect(thinkingConfig.includeThoughts).toBe(true);\n      expect(thinkingConfig).not.toHaveProperty(\"thinkingBudget\");\n    });\n\n    describe(\"Google Search (Grounding)\", () => {\n      it(\"injects googleSearch tool when mode is 'auto'\", () => {\n        const payload: RequestPayload = { contents: [], tools: [] };\n        applyGeminiTransforms(payload, {\n          model: \"gemini-3-pro\",\n          googleSearch: { mode: \"auto\" },\n        });\n        const tools = payload.tools as unknown[];\n        expect(tools).toHaveLength(1);\n        expect(tools[0]).toEqual({\n          googleSearch: {},\n        });\n      });\n\n      it(\"ignores threshold value (deprecated in new API)\", () => {\n        const payload: RequestPayload = { contents: [] };\n        applyGeminiTransforms(payload, {\n          model: \"gemini-3-flash\",\n          googleSearch: { mode: \"auto\", threshold: 0.7 },\n        });\n        const tools = payload.tools as unknown[];\n        const searchTool = tools[0] as Record<string, unknown>;\n        // New API uses simple googleSearch: {} without threshold\n        expect(searchTool).toEqual({ googleSearch: {} });\n      });\n\n      it(\"works without threshold specified\", () => {\n        const payload: RequestPayload = { contents: [] };\n        applyGeminiTransforms(payload, {\n          model: \"gemini-3-pro\",\n          googleSearch: { mode: \"auto\" },\n        });\n        const tools = payload.tools as unknown[];\n        const searchTool = tools[0] as Record<string, unknown>;\n        expect(searchTool).toEqual({ googleSearch: {} });\n      });\n\n      it(\"does not inject search tool when mode is 'off'\", () => {\n        const payload: RequestPayload = { contents: [], tools: [] };\n        applyGeminiTransforms(payload, {\n          model: \"gemini-3-pro\",\n          googleSearch: { mode: \"off\" },\n        });\n        const tools = payload.tools as unknown[];\n        expect(tools).toHaveLength(0);\n      });\n\n      it(\"does not inject search tool when googleSearch is undefined\", () => {\n        const payload: RequestPayload = { contents: [], tools: [] };\n        applyGeminiTransforms(payload, {\n          model: \"gemini-3-pro\",\n        });\n        const tools = payload.tools as unknown[];\n        expect(tools).toHaveLength(0);\n      });\n\n      it(\"appends search tool to existing tools array\", () => {\n        const payload: RequestPayload = {\n          contents: [],\n          tools: [\n            { function: { name: \"existing_tool\", input_schema: { type: \"object\" } } },\n          ],\n        };\n        applyGeminiTransforms(payload, {\n          model: \"gemini-3-pro\",\n          googleSearch: { mode: \"auto\" },\n        });\n        const tools = payload.tools as unknown[];\n        expect(tools).toHaveLength(2);\n        const lastTool = tools[1] as Record<string, unknown>;\n        expect(lastTool).toHaveProperty(\"googleSearch\");\n      });\n\n      it(\"search tool is not normalized (skipped by normalizeGeminiTools)\", () => {\n        const payload: RequestPayload = { contents: [] };\n        applyGeminiTransforms(payload, {\n          model: \"gemini-3-pro\",\n          googleSearch: { mode: \"auto\" },\n        });\n        const tools = payload.tools as unknown[];\n        const searchTool = tools[0] as Record<string, unknown>;\n        expect(searchTool).toHaveProperty(\"googleSearch\");\n        expect(searchTool).not.toHaveProperty(\"function\");\n        expect(searchTool).not.toHaveProperty(\"custom\");\n      });\n    });\n  });\n\n  describe(\"isImageGenerationModel\", () => {\n    it(\"returns true for gemini-3-pro-image\", () => {\n      expect(isImageGenerationModel(\"gemini-3-pro-image\")).toBe(true);\n    });\n\n    it(\"returns true for gemini-3-pro-image-preview\", () => {\n      expect(isImageGenerationModel(\"gemini-3-pro-image-preview\")).toBe(true);\n    });\n\n    it(\"returns true for gemini-2.5-flash-image\", () => {\n      expect(isImageGenerationModel(\"gemini-2.5-flash-image\")).toBe(true);\n    });\n\n    it(\"returns true for imagen-3\", () => {\n      expect(isImageGenerationModel(\"imagen-3\")).toBe(true);\n    });\n\n    it(\"returns true for uppercase GEMINI-3-PRO-IMAGE\", () => {\n      expect(isImageGenerationModel(\"GEMINI-3-PRO-IMAGE\")).toBe(true);\n    });\n\n    it(\"returns false for gemini-3-pro\", () => {\n      expect(isImageGenerationModel(\"gemini-3-pro\")).toBe(false);\n    });\n\n    it(\"returns false for gemini-2.5-flash\", () => {\n      expect(isImageGenerationModel(\"gemini-2.5-flash\")).toBe(false);\n    });\n\n    it(\"returns false for claude-sonnet-4-6\", () => {\n      expect(isImageGenerationModel(\"claude-sonnet-4-6\")).toBe(false);\n    });\n  });\n\n  describe(\"buildImageGenerationConfig\", () => {\n    const originalEnv = process.env;\n\n    beforeEach(() => {\n      // Reset environment before each test\n      vi.resetModules();\n      process.env = { ...originalEnv };\n    });\n\n    afterEach(() => {\n      process.env = originalEnv;\n    });\n\n    it(\"returns default 1:1 aspect ratio when no env var set\", () => {\n      delete process.env.OPENCODE_IMAGE_ASPECT_RATIO;\n      const config = buildImageGenerationConfig();\n      expect(config).toEqual({ aspectRatio: \"1:1\" });\n    });\n\n    it(\"uses OPENCODE_IMAGE_ASPECT_RATIO env var when set to valid value\", () => {\n      process.env.OPENCODE_IMAGE_ASPECT_RATIO = \"16:9\";\n      const config = buildImageGenerationConfig();\n      expect(config).toEqual({ aspectRatio: \"16:9\" });\n    });\n\n    it(\"accepts all valid aspect ratios\", () => {\n      const validRatios = [\"1:1\", \"2:3\", \"3:2\", \"3:4\", \"4:3\", \"4:5\", \"5:4\", \"9:16\", \"16:9\", \"21:9\"];\n      for (const ratio of validRatios) {\n        process.env.OPENCODE_IMAGE_ASPECT_RATIO = ratio;\n        const config = buildImageGenerationConfig();\n        expect(config.aspectRatio).toBe(ratio);\n      }\n    });\n\n    it(\"falls back to 1:1 for invalid aspect ratio\", () => {\n      process.env.OPENCODE_IMAGE_ASPECT_RATIO = \"invalid\";\n      const config = buildImageGenerationConfig();\n      expect(config).toEqual({ aspectRatio: \"1:1\" });\n    });\n\n    it(\"falls back to 1:1 for unsupported aspect ratio\", () => {\n      process.env.OPENCODE_IMAGE_ASPECT_RATIO = \"5:3\";\n      const config = buildImageGenerationConfig();\n      expect(config).toEqual({ aspectRatio: \"1:1\" });\n    });\n  });\n\n  describe(\"toGeminiSchema\", () => {\n    it(\"returns null/undefined as-is\", () => {\n      expect(toGeminiSchema(null)).toBe(null);\n      expect(toGeminiSchema(undefined)).toBe(undefined);\n    });\n\n    it(\"returns primitives as-is\", () => {\n      expect(toGeminiSchema(\"string\")).toBe(\"string\");\n      expect(toGeminiSchema(123)).toBe(123);\n      expect(toGeminiSchema(true)).toBe(true);\n    });\n\n    it(\"returns arrays as-is\", () => {\n      const arr = [1, 2, 3];\n      expect(toGeminiSchema(arr)).toBe(arr);\n    });\n\n    it(\"converts type to uppercase\", () => {\n      expect(toGeminiSchema({ type: \"object\" })).toEqual({ type: \"OBJECT\" });\n      expect(toGeminiSchema({ type: \"string\" })).toEqual({ type: \"STRING\" });\n      expect(toGeminiSchema({ type: \"boolean\" })).toEqual({ type: \"BOOLEAN\" });\n      expect(toGeminiSchema({ type: \"number\" })).toEqual({ type: \"NUMBER\" });\n      expect(toGeminiSchema({ type: \"integer\" })).toEqual({ type: \"INTEGER\" });\n      expect(toGeminiSchema({ type: \"array\" })).toEqual({ type: \"ARRAY\", items: { type: \"STRING\" } });\n    });\n\n    it(\"removes additionalProperties field\", () => {\n      const schema = {\n        type: \"object\",\n        properties: { foo: { type: \"string\" } },\n        additionalProperties: false,\n      };\n      const result = toGeminiSchema(schema) as Record<string, unknown>;\n      expect(result).not.toHaveProperty(\"additionalProperties\");\n      expect(result.type).toBe(\"OBJECT\");\n    });\n\n    it(\"removes $schema field\", () => {\n      const schema = {\n        $schema: \"http://json-schema.org/draft-07/schema#\",\n        type: \"object\",\n      };\n      const result = toGeminiSchema(schema) as Record<string, unknown>;\n      expect(result).not.toHaveProperty(\"$schema\");\n      expect(result.type).toBe(\"OBJECT\");\n    });\n\n    it(\"removes $id and $comment fields\", () => {\n      const schema = {\n        $id: \"my-schema\",\n        $comment: \"This is a comment\",\n        type: \"object\",\n      };\n      const result = toGeminiSchema(schema) as Record<string, unknown>;\n      expect(result).not.toHaveProperty(\"$id\");\n      expect(result).not.toHaveProperty(\"$comment\");\n      expect(result.type).toBe(\"OBJECT\");\n    });\n\n    it(\"recursively transforms properties\", () => {\n      const schema = {\n        type: \"object\",\n        properties: {\n          name: { type: \"string\" },\n          age: { type: \"number\" },\n          active: { type: \"boolean\" },\n        },\n      };\n      const result = toGeminiSchema(schema) as Record<string, unknown>;\n      const props = result.properties as Record<string, Record<string, string>>;\n      expect(props[\"name\"]!.type).toBe(\"STRING\");\n      expect(props[\"age\"]!.type).toBe(\"NUMBER\");\n      expect(props[\"active\"]!.type).toBe(\"BOOLEAN\");\n    });\n\n    it(\"transforms nested objects recursively\", () => {\n      const schema = {\n        type: \"object\",\n        properties: {\n          user: {\n            type: \"object\",\n            properties: {\n              email: { type: \"string\" },\n            },\n            additionalProperties: false,\n          },\n        },\n      };\n      const result = toGeminiSchema(schema) as Record<string, unknown>;\n      const props = result.properties as Record<string, Record<string, unknown>>;\n      expect(props[\"user\"]!.type).toBe(\"OBJECT\");\n      expect(props[\"user\"]).not.toHaveProperty(\"additionalProperties\");\n      const userProps = props[\"user\"]!.properties as Record<string, Record<string, string>>;\n      expect(userProps[\"email\"]!.type).toBe(\"STRING\");\n    });\n\n    it(\"transforms array items schema\", () => {\n      const schema = {\n        type: \"array\",\n        items: {\n          type: \"object\",\n          properties: {\n            id: { type: \"number\" },\n          },\n        },\n      };\n      const result = toGeminiSchema(schema) as Record<string, unknown>;\n      expect(result.type).toBe(\"ARRAY\");\n      const items = result.items as Record<string, unknown>;\n      expect(items.type).toBe(\"OBJECT\");\n      const itemProps = items.properties as Record<string, Record<string, string>>;\n      expect(itemProps[\"id\"]!.type).toBe(\"NUMBER\");\n    });\n\n    it(\"transforms anyOf schemas\", () => {\n      const schema = {\n        anyOf: [\n          { type: \"string\" },\n          { type: \"number\" },\n        ],\n      };\n      const result = toGeminiSchema(schema) as Record<string, unknown>;\n      const anyOf = result.anyOf as Array<Record<string, string>>;\n      expect(anyOf[0]!.type).toBe(\"STRING\");\n      expect(anyOf[1]!.type).toBe(\"NUMBER\");\n    });\n\n    it(\"transforms oneOf schemas\", () => {\n      const schema = {\n        oneOf: [\n          { type: \"boolean\" },\n          { type: \"string\" },\n        ],\n      };\n      const result = toGeminiSchema(schema) as Record<string, unknown>;\n      const oneOf = result.oneOf as Array<Record<string, string>>;\n      expect(oneOf[0]!.type).toBe(\"BOOLEAN\");\n      expect(oneOf[1]!.type).toBe(\"STRING\");\n    });\n\n    it(\"transforms allOf schemas\", () => {\n      const schema = {\n        allOf: [\n          { type: \"object\", properties: { a: { type: \"string\" } } },\n          { properties: { b: { type: \"number\" } } },\n        ],\n      };\n      const result = toGeminiSchema(schema) as Record<string, unknown>;\n      const allOf = result.allOf as Array<Record<string, unknown>>;\n      expect(allOf[0]!.type).toBe(\"OBJECT\");\n      const props0 = allOf[0]!.properties as Record<string, Record<string, string>>;\n      expect(props0[\"a\"]!.type).toBe(\"STRING\");\n      const props1 = allOf[1]!.properties as Record<string, Record<string, string>>;\n      expect(props1[\"b\"]!.type).toBe(\"NUMBER\");\n    });\n\n    it(\"preserves enum values\", () => {\n      const schema = {\n        type: \"string\",\n        enum: [\"low\", \"medium\", \"high\"],\n      };\n      const result = toGeminiSchema(schema) as Record<string, unknown>;\n      expect(result.type).toBe(\"STRING\");\n      expect(result.enum).toEqual([\"low\", \"medium\", \"high\"]);\n    });\n\n    it(\"preserves required array when all properties exist\", () => {\n      const schema = {\n        type: \"object\",\n        properties: {\n          name: { type: \"string\" },\n        },\n        required: [\"name\"],\n      };\n      const result = toGeminiSchema(schema) as Record<string, unknown>;\n      expect(result.required).toEqual([\"name\"]);\n    });\n\n    it(\"filters required array to only include existing properties\", () => {\n      // This fixes: \"parameters.required[X]: property is not defined\"\n      const schema = {\n        type: \"object\",\n        properties: {\n          name: { type: \"string\" },\n          age: { type: \"number\" },\n        },\n        required: [\"name\", \"nonexistent\", \"age\", \"alsoMissing\"],\n      };\n      const result = toGeminiSchema(schema) as Record<string, unknown>;\n      expect(result.required).toEqual([\"name\", \"age\"]);\n    });\n\n    it(\"omits required field when no valid properties remain\", () => {\n      const schema = {\n        type: \"object\",\n        properties: {\n          name: { type: \"string\" },\n        },\n        required: [\"nonexistent\", \"alsoMissing\"],\n      };\n      const result = toGeminiSchema(schema) as Record<string, unknown>;\n      expect(result).not.toHaveProperty(\"required\");\n    });\n\n    it(\"handles MCP tool with missing properties in required (issue #161)\", () => {\n      // Simulates the group_execute_tool schema from issue #161\n      const schema = {\n        type: \"object\",\n        properties: {\n          mcp_name: { type: \"string\", enum: [\"exa-mcp-server\", \"context7\"] },\n          tool_name: { type: \"string\" },\n          // Note: \"arguments\" is missing from properties but present in required\n        },\n        required: [\"mcp_name\", \"tool_name\", \"arguments\"],\n      };\n      const result = toGeminiSchema(schema) as Record<string, unknown>;\n      // Should filter out \"arguments\" since it doesn't exist in properties\n      expect(result.required).toEqual([\"mcp_name\", \"tool_name\"]);\n      expect(result.type).toBe(\"OBJECT\");\n    });\n\n    it(\"preserves description\", () => {\n      const schema = {\n        type: \"string\",\n        description: \"User's full name\",\n      };\n      const result = toGeminiSchema(schema) as Record<string, unknown>;\n      expect(result.description).toBe(\"User's full name\");\n    });\n\n    it(\"preserves default value\", () => {\n      const schema = {\n        type: \"number\",\n        default: 42,\n      };\n      const result = toGeminiSchema(schema) as Record<string, unknown>;\n      expect(result.default).toBe(42);\n    });\n\n    it(\"handles complex real-world MCP schema\", () => {\n      // Simulates a PostHog-like complex schema with enums and nested types\n      const schema = {\n        $schema: \"http://json-schema.org/draft-07/schema#\",\n        type: \"object\",\n        properties: {\n          event_name: {\n            type: \"string\",\n            description: \"Event name to track\",\n          },\n          properties: {\n            type: \"object\",\n            additionalProperties: true,\n            description: \"Event properties\",\n          },\n          level: {\n            type: \"string\",\n            enum: [\"info\", \"warning\", \"error\"],\n          },\n          items: {\n            type: \"array\",\n            items: {\n              type: \"object\",\n              properties: {\n                id: { type: \"string\" },\n                value: { type: \"number\" },\n              },\n              additionalProperties: false,\n            },\n          },\n        },\n        required: [\"event_name\"],\n        additionalProperties: false,\n      };\n      const result = toGeminiSchema(schema) as Record<string, unknown>;\n      \n      // Should remove unsupported fields\n      expect(result).not.toHaveProperty(\"$schema\");\n      expect(result).not.toHaveProperty(\"additionalProperties\");\n      \n      // Should uppercase types\n      expect(result.type).toBe(\"OBJECT\");\n      \n      const props = result.properties as Record<string, Record<string, unknown>>;\n      expect(props[\"event_name\"]!.type).toBe(\"STRING\");\n      expect(props[\"properties\"]!.type).toBe(\"OBJECT\");\n      expect(props[\"properties\"]).not.toHaveProperty(\"additionalProperties\");\n      expect(props[\"level\"]!.type).toBe(\"STRING\");\n      expect(props[\"level\"]!.enum).toEqual([\"info\", \"warning\", \"error\"]);\n      expect(props[\"items\"]!.type).toBe(\"ARRAY\");\n      \n      const itemsSchema = props[\"items\"]!.items as Record<string, unknown>;\n      expect(itemsSchema.type).toBe(\"OBJECT\");\n      expect(itemsSchema).not.toHaveProperty(\"additionalProperties\");\n      \n      const itemProps = itemsSchema.properties as Record<string, Record<string, string>>;\n      expect(itemProps[\"id\"]!.type).toBe(\"STRING\");\n      expect(itemProps[\"value\"]!.type).toBe(\"NUMBER\");\n      \n      // Should preserve required\n      expect(result.required).toEqual([\"event_name\"]);\n    });\n  });\n\n  describe(\"normalizeGeminiTools schema transformation\", () => {\n    it(\"transforms tool schemas to Gemini format with uppercase types\", () => {\n      const payload: RequestPayload = {\n        contents: [],\n        tools: [\n          {\n            function: {\n              name: \"test_tool\",\n              description: \"A test tool\",\n              input_schema: { \n                type: \"object\", \n                properties: { \n                  name: { type: \"string\" },\n                  count: { type: \"number\" },\n                },\n              },\n            },\n          },\n        ],\n      };\n      normalizeGeminiTools(payload);\n      \n      const tool = (payload.tools as unknown[])[0] as Record<string, unknown>;\n      const func = tool.function as Record<string, unknown>;\n      const schema = func.input_schema as Record<string, unknown>;\n      \n      expect(schema.type).toBe(\"OBJECT\");\n      const props = schema.properties as Record<string, Record<string, string>>;\n      expect(props[\"name\"]!.type).toBe(\"STRING\");\n      expect(props[\"count\"]!.type).toBe(\"NUMBER\");\n    });\n\n    it(\"removes additionalProperties from tool schemas\", () => {\n      const payload: RequestPayload = {\n        contents: [],\n        tools: [\n          {\n            function: {\n              name: \"strict_tool\",\n              input_schema: { \n                type: \"object\", \n                properties: {},\n                additionalProperties: false,\n              },\n            },\n          },\n        ],\n      };\n      normalizeGeminiTools(payload);\n      \n      const tool = (payload.tools as unknown[])[0] as Record<string, unknown>;\n      const func = tool.function as Record<string, unknown>;\n      const schema = func.input_schema as Record<string, unknown>;\n      \n      expect(schema).not.toHaveProperty(\"additionalProperties\");\n      expect(schema.type).toBe(\"OBJECT\");\n    });\n\n    it(\"uses uppercase placeholder schema for tools without schemas\", () => {\n      const payload: RequestPayload = {\n        contents: [],\n        tools: [{ name: \"schema_less_tool\" }],\n      };\n      const result = normalizeGeminiTools(payload);\n      \n      expect(result.toolDebugMissing).toBe(1);\n      \n      // Check that placeholder uses uppercase types\n      const tool = (payload.tools as unknown[])[0] as Record<string, unknown>;\n      const params = tool.parameters as Record<string, unknown>;\n      expect(params.type).toBe(\"OBJECT\");\n      \n      const props = params.properties as Record<string, Record<string, string>>;\n      expect(props[\"_placeholder\"]!.type).toBe(\"BOOLEAN\");\n    });\n  });\n\n  describe(\"wrapToolsAsFunctionDeclarations (fixes #203, #206)\", () => {\n    it(\"wraps tools in functionDeclarations format\", () => {\n      const payload: RequestPayload = {\n        contents: [],\n        tools: [\n          {\n            name: \"read_file\",\n            description: \"Read a file\",\n            parameters: { type: \"OBJECT\", properties: { path: { type: \"STRING\" } } },\n          },\n        ],\n      };\n      wrapToolsAsFunctionDeclarations(payload);\n      \n      const tools = payload.tools as Array<Record<string, unknown>>;\n      expect(tools).toHaveLength(1);\n      expect(tools[0]).toHaveProperty(\"functionDeclarations\");\n      expect(tools[0]).not.toHaveProperty(\"parameters\");\n      \n      const decls = tools[0]!.functionDeclarations as Array<Record<string, unknown>>;\n      expect(decls).toHaveLength(1);\n      expect(decls[0]!.name).toBe(\"read_file\");\n      expect(decls[0]!.description).toBe(\"Read a file\");\n      expect(decls[0]!.parameters).toEqual({ type: \"OBJECT\", properties: { path: { type: \"STRING\" } } });\n    });\n\n    it(\"extracts schema from function.input_schema\", () => {\n      const payload: RequestPayload = {\n        contents: [],\n        tools: [\n          {\n            function: {\n              name: \"test_fn\",\n              description: \"Test function\",\n              input_schema: { type: \"OBJECT\", properties: {} },\n            },\n          },\n        ],\n      };\n      wrapToolsAsFunctionDeclarations(payload);\n      \n      const tools = payload.tools as Array<Record<string, unknown>>;\n      const decls = tools[0]!.functionDeclarations as Array<Record<string, unknown>>;\n      expect(decls[0]!.name).toBe(\"test_fn\");\n      expect(decls[0]!.parameters).toEqual({ type: \"OBJECT\", properties: {} });\n    });\n\n    it(\"extracts schema from custom.input_schema\", () => {\n      const payload: RequestPayload = {\n        contents: [],\n        tools: [\n          {\n            custom: {\n              name: \"custom_fn\",\n              description: \"Custom function\",\n              input_schema: { type: \"OBJECT\", properties: { x: { type: \"NUMBER\" } } },\n            },\n          },\n        ],\n      };\n      wrapToolsAsFunctionDeclarations(payload);\n      \n      const tools = payload.tools as Array<Record<string, unknown>>;\n      const decls = tools[0]!.functionDeclarations as Array<Record<string, unknown>>;\n      expect(decls[0]!.name).toBe(\"custom_fn\");\n      expect(decls[0]!.parameters).toEqual({ type: \"OBJECT\", properties: { x: { type: \"NUMBER\" } } });\n    });\n\n    it(\"preserves googleSearch tools as passthrough (new API)\", () => {\n      const payload: RequestPayload = {\n        contents: [],\n        tools: [\n          { name: \"tool1\", parameters: { type: \"OBJECT\", properties: {} } },\n          { googleSearch: {} },\n        ],\n      };\n      wrapToolsAsFunctionDeclarations(payload);\n\n      const tools = payload.tools as Array<Record<string, unknown>>;\n      expect(tools).toHaveLength(2);\n      expect(tools[0]).toHaveProperty(\"functionDeclarations\");\n      expect(tools[1]).toHaveProperty(\"googleSearch\");\n    });\n\n    it(\"preserves googleSearchRetrieval tools as passthrough (legacy API)\", () => {\n      const payload: RequestPayload = {\n        contents: [],\n        tools: [\n          { name: \"tool1\", parameters: { type: \"OBJECT\", properties: {} } },\n          {\n            googleSearchRetrieval: {\n              dynamicRetrievalConfig: { mode: \"MODE_DYNAMIC\", dynamicThreshold: 0.3 },\n            },\n          },\n        ],\n      };\n      wrapToolsAsFunctionDeclarations(payload);\n\n      const tools = payload.tools as Array<Record<string, unknown>>;\n      expect(tools).toHaveLength(2);\n      expect(tools[0]).toHaveProperty(\"functionDeclarations\");\n      expect(tools[1]).toHaveProperty(\"googleSearchRetrieval\");\n    });\n\n    it(\"preserves codeExecution tools as passthrough\", () => {\n      const payload: RequestPayload = {\n        contents: [],\n        tools: [\n          { name: \"tool1\", parameters: { type: \"OBJECT\", properties: {} } },\n          { codeExecution: {} },\n        ],\n      };\n      wrapToolsAsFunctionDeclarations(payload);\n      \n      const tools = payload.tools as Array<Record<string, unknown>>;\n      expect(tools).toHaveLength(2);\n      expect(tools[0]).toHaveProperty(\"functionDeclarations\");\n      expect(tools[1]).toHaveProperty(\"codeExecution\");\n    });\n\n    it(\"merges existing functionDeclarations into output\", () => {\n      const payload: RequestPayload = {\n        contents: [],\n        tools: [\n          {\n            functionDeclarations: [\n              { name: \"existing\", description: \"Existing fn\", parameters: { type: \"OBJECT\" } },\n            ],\n          },\n          { name: \"new_tool\", parameters: { type: \"OBJECT\", properties: {} } },\n        ],\n      };\n      wrapToolsAsFunctionDeclarations(payload);\n      \n      const tools = payload.tools as Array<Record<string, unknown>>;\n      expect(tools).toHaveLength(1);\n      const decls = tools[0]!.functionDeclarations as Array<Record<string, unknown>>;\n      expect(decls).toHaveLength(2);\n      expect(decls[0]!.name).toBe(\"existing\");\n      expect(decls[1]!.name).toBe(\"new_tool\");\n    });\n\n    it(\"handles multiple tools correctly\", () => {\n      const payload: RequestPayload = {\n        contents: [],\n        tools: [\n          { name: \"tool1\", description: \"First\", parameters: { type: \"OBJECT\" } },\n          { name: \"tool2\", description: \"Second\", parameters: { type: \"OBJECT\" } },\n          { name: \"tool3\", description: \"Third\", parameters: { type: \"OBJECT\" } },\n        ],\n      };\n      wrapToolsAsFunctionDeclarations(payload);\n      \n      const tools = payload.tools as Array<Record<string, unknown>>;\n      expect(tools).toHaveLength(1);\n      const decls = tools[0]!.functionDeclarations as Array<Record<string, unknown>>;\n      expect(decls).toHaveLength(3);\n      expect(decls.map(d => d.name)).toEqual([\"tool1\", \"tool2\", \"tool3\"]);\n    });\n\n    it(\"provides default schema when no schema found\", () => {\n      const payload: RequestPayload = {\n        contents: [],\n        tools: [{ name: \"no_schema_tool\" }],\n      };\n      wrapToolsAsFunctionDeclarations(payload);\n      \n      const tools = payload.tools as Array<Record<string, unknown>>;\n      const decls = tools[0]!.functionDeclarations as Array<Record<string, unknown>>;\n      expect(decls[0]!.parameters).toEqual({ type: \"OBJECT\", properties: {} });\n    });\n\n    it(\"generates default name when missing\", () => {\n      const payload: RequestPayload = {\n        contents: [],\n        tools: [{ description: \"Anonymous tool\", parameters: { type: \"OBJECT\" } }],\n      };\n      wrapToolsAsFunctionDeclarations(payload);\n      \n      const tools = payload.tools as Array<Record<string, unknown>>;\n      const decls = tools[0]!.functionDeclarations as Array<Record<string, unknown>>;\n      expect(decls[0]!.name).toBe(\"tool-0\");\n    });\n\n    it(\"does nothing when tools is empty\", () => {\n      const payload: RequestPayload = { contents: [], tools: [] };\n      wrapToolsAsFunctionDeclarations(payload);\n      expect(payload.tools).toEqual([]);\n    });\n\n    it(\"does nothing when tools is undefined\", () => {\n      const payload: RequestPayload = { contents: [] };\n      wrapToolsAsFunctionDeclarations(payload);\n      expect(payload.tools).toBeUndefined();\n    });\n  });\n\n  describe(\"toGeminiSchema - array items fix (issue #80)\", () => {\n    it(\"adds default items to array schema without items\", () => {\n      const schema = { type: \"array\" };\n      const result = toGeminiSchema(schema) as Record<string, unknown>;\n      expect(result.type).toBe(\"ARRAY\");\n      expect(result.items).toEqual({ type: \"STRING\" });\n    });\n\n    it(\"preserves existing items in array schema\", () => {\n      const schema = {\n        type: \"array\",\n        items: { type: \"object\", properties: { id: { type: \"string\" } } },\n      };\n      const result = toGeminiSchema(schema) as Record<string, unknown>;\n      expect(result.type).toBe(\"ARRAY\");\n      const items = result.items as Record<string, unknown>;\n      expect(items.type).toBe(\"OBJECT\");\n      const props = items.properties as Record<string, Record<string, string>>;\n      expect(props[\"id\"]!.type).toBe(\"STRING\");\n    });\n\n    it(\"handles nested array without items\", () => {\n      const schema = {\n        type: \"object\",\n        properties: {\n          tags: { type: \"array\" },\n        },\n      };\n      const result = toGeminiSchema(schema) as Record<string, unknown>;\n      const props = result.properties as Record<string, Record<string, unknown>>;\n      expect(props[\"tags\"]!.type).toBe(\"ARRAY\");\n      expect(props[\"tags\"]!.items).toEqual({ type: \"STRING\" });\n    });\n  });\n\n  describe(\"toGeminiSchema - unsupported fields removal (issue #161)\", () => {\n    it(\"removes $ref field\", () => {\n      const schema = { $ref: \"#/definitions/MyType\", type: \"object\" };\n      const result = toGeminiSchema(schema) as Record<string, unknown>;\n      expect(result).not.toHaveProperty(\"$ref\");\n      expect(result.type).toBe(\"OBJECT\");\n    });\n\n    it(\"removes $defs field\", () => {\n      const schema = {\n        type: \"object\",\n        $defs: { MyType: { type: \"string\" } },\n        properties: { name: { type: \"string\" } },\n      };\n      const result = toGeminiSchema(schema) as Record<string, unknown>;\n      expect(result).not.toHaveProperty(\"$defs\");\n    });\n\n    it(\"removes definitions field\", () => {\n      const schema = {\n        type: \"object\",\n        definitions: { MyType: { type: \"string\" } },\n      };\n      const result = toGeminiSchema(schema) as Record<string, unknown>;\n      expect(result).not.toHaveProperty(\"definitions\");\n    });\n\n    it(\"removes const field\", () => {\n      const schema = { const: \"fixed_value\" };\n      const result = toGeminiSchema(schema) as Record<string, unknown>;\n      expect(result).not.toHaveProperty(\"const\");\n    });\n\n    it(\"removes conditional schema fields (if/then/else/not)\", () => {\n      const schema = {\n        type: \"object\",\n        if: { properties: { type: { const: \"a\" } } },\n        then: { properties: { a: { type: \"string\" } } },\n        else: { properties: { b: { type: \"string\" } } },\n        not: { type: \"null\" },\n      };\n      const result = toGeminiSchema(schema) as Record<string, unknown>;\n      expect(result).not.toHaveProperty(\"if\");\n      expect(result).not.toHaveProperty(\"then\");\n      expect(result).not.toHaveProperty(\"else\");\n      expect(result).not.toHaveProperty(\"not\");\n    });\n\n    it(\"removes patternProperties and propertyNames\", () => {\n      const schema = {\n        type: \"object\",\n        patternProperties: { \"^S_\": { type: \"string\" } },\n        propertyNames: { pattern: \"^[a-z]+$\" },\n      };\n      const result = toGeminiSchema(schema) as Record<string, unknown>;\n      expect(result).not.toHaveProperty(\"patternProperties\");\n      expect(result).not.toHaveProperty(\"propertyNames\");\n    });\n\n    it(\"removes unevaluatedProperties and unevaluatedItems\", () => {\n      const schema = {\n        type: \"object\",\n        unevaluatedProperties: false,\n        unevaluatedItems: false,\n      };\n      const result = toGeminiSchema(schema) as Record<string, unknown>;\n      expect(result).not.toHaveProperty(\"unevaluatedProperties\");\n      expect(result).not.toHaveProperty(\"unevaluatedItems\");\n    });\n\n    it(\"removes contentMediaType and contentEncoding\", () => {\n      const schema = {\n        type: \"string\",\n        contentMediaType: \"application/json\",\n        contentEncoding: \"base64\",\n      };\n      const result = toGeminiSchema(schema) as Record<string, unknown>;\n      expect(result).not.toHaveProperty(\"contentMediaType\");\n      expect(result).not.toHaveProperty(\"contentEncoding\");\n    });\n\n    it(\"removes dependentRequired and dependentSchemas\", () => {\n      const schema = {\n        type: \"object\",\n        dependentRequired: { credit_card: [\"billing_address\"] },\n        dependentSchemas: { name: { properties: { age: { type: \"number\" } } } },\n      };\n      const result = toGeminiSchema(schema) as Record<string, unknown>;\n      expect(result).not.toHaveProperty(\"dependentRequired\");\n      expect(result).not.toHaveProperty(\"dependentSchemas\");\n    });\n\n    it(\"handles complex MCP schema with all unsupported fields\", () => {\n      const complexSchema = {\n        $schema: \"http://json-schema.org/draft-07/schema#\",\n        $id: \"complex-mcp-schema\",\n        $comment: \"This is a complex schema\",\n        $ref: \"#/definitions/Base\",\n        $defs: { Base: { type: \"object\" } },\n        definitions: { Legacy: { type: \"string\" } },\n        type: \"object\",\n        properties: {\n          name: { type: \"string\", const: \"fixed\" },\n          data: { \n            type: \"array\",\n            items: { type: \"object\" },\n            minContains: 1,\n            maxContains: 10,\n          },\n        },\n        additionalProperties: false,\n        patternProperties: { \"^x-\": { type: \"string\" } },\n        propertyNames: { minLength: 1 },\n        unevaluatedProperties: false,\n        if: { properties: { type: { const: \"a\" } } },\n        then: { required: [\"a\"] },\n        else: { required: [\"b\"] },\n        not: { type: \"null\" },\n        dependentRequired: { foo: [\"bar\"] },\n        dependentSchemas: {},\n        contentMediaType: \"application/json\",\n        contentEncoding: \"utf-8\",\n        required: [\"name\", \"missing_prop\"],\n      };\n      \n      const result = toGeminiSchema(complexSchema) as Record<string, unknown>;\n      \n      const unsupportedFields = [\n        \"$schema\", \"$id\", \"$comment\", \"$ref\", \"$defs\", \"definitions\",\n        \"additionalProperties\", \"patternProperties\", \"propertyNames\",\n        \"unevaluatedProperties\", \"if\", \"then\", \"else\", \"not\",\n        \"dependentRequired\", \"dependentSchemas\", \"contentMediaType\", \"contentEncoding\",\n      ];\n      \n      for (const field of unsupportedFields) {\n        expect(result).not.toHaveProperty(field);\n      }\n      \n      expect(result.type).toBe(\"OBJECT\");\n      expect(result.required).toEqual([\"name\"]);\n      \n      const props = result.properties as Record<string, Record<string, unknown>>;\n      expect(props[\"name\"]!.type).toBe(\"STRING\");\n      expect(props[\"name\"]).not.toHaveProperty(\"const\");\n      expect(props[\"data\"]!.type).toBe(\"ARRAY\");\n      expect(props[\"data\"]).not.toHaveProperty(\"minContains\");\n      expect(props[\"data\"]).not.toHaveProperty(\"maxContains\");\n    });\n  });\n\n  describe(\"applyGeminiTransforms - full integration\", () => {\n    it(\"wraps tools in functionDeclarations after normalization\", () => {\n      const payload: RequestPayload = {\n        contents: [],\n        tools: [\n          {\n            function: {\n              name: \"test_tool\",\n              description: \"A test\",\n              input_schema: { type: \"object\", properties: { x: { type: \"string\" } } },\n            },\n          },\n        ],\n      };\n      \n      applyGeminiTransforms(payload, { model: \"gemini-3-pro\" });\n      \n      const tools = payload.tools as Array<Record<string, unknown>>;\n      expect(tools).toHaveLength(1);\n      expect(tools[0]).toHaveProperty(\"functionDeclarations\");\n      expect(tools[0]).not.toHaveProperty(\"function\");\n      expect(tools[0]).not.toHaveProperty(\"parameters\");\n      \n      const decls = tools[0]!.functionDeclarations as Array<Record<string, unknown>>;\n      expect(decls[0]!.name).toBe(\"test_tool\");\n      \n      const params = decls[0]!.parameters as Record<string, unknown>;\n      expect(params.type).toBe(\"OBJECT\");\n      const props = params.properties as Record<string, Record<string, string>>;\n      expect(props[\"x\"]!.type).toBe(\"STRING\");\n    });\n\n    it(\"handles mixed tools and googleSearch\", () => {\n      const payload: RequestPayload = {\n        contents: [],\n        tools: [\n          { name: \"my_tool\", parameters: { type: \"object\" } },\n        ],\n      };\n\n      applyGeminiTransforms(payload, {\n        model: \"gemini-3-pro\",\n        googleSearch: { mode: \"auto\" },\n      });\n      \n      const tools = payload.tools as Array<Record<string, unknown>>;\n      expect(tools).toHaveLength(2);\n      expect(tools[0]).toHaveProperty(\"functionDeclarations\");\n      expect(tools[1]).toHaveProperty(\"googleSearch\");\n    });\n  });\n});\n"
  },
  {
    "path": "src/plugin/transform/gemini.ts",
    "content": "/**\n * Gemini-specific Request Transformations\n * \n * Handles Gemini model-specific request transformations including:\n * - Thinking config (camelCase keys, thinkingLevel for Gemini 3)\n * - Tool normalization (function/custom format)\n * - Schema transformation (JSON Schema -> Gemini Schema format)\n */\n\nimport type { RequestPayload, ThinkingConfig, ThinkingTier, GoogleSearchConfig } from \"./types\";\n\n/**\n * Transform a JSON Schema to Gemini-compatible format.\n * Based on @google/genai SDK's processJsonSchema() function.\n * \n * Key transformations:\n * - Converts type values to uppercase (object -> OBJECT)\n * - Removes unsupported fields like additionalProperties, $schema\n * - Recursively processes nested schemas (properties, items, anyOf, etc.)\n * \n * @param schema - A JSON Schema object or primitive value\n * @returns Gemini-compatible schema\n * \n * Fields that Gemini API rejects and must be removed from schemas.\n * Antigravity uses strict protobuf-backed JSON validation.\n */\nconst UNSUPPORTED_SCHEMA_FIELDS = new Set([\n  \"additionalProperties\",\n  \"$schema\",\n  \"$id\",\n  \"$comment\",\n  \"$ref\",\n  \"$defs\",\n  \"definitions\",\n  \"const\",\n  \"contentMediaType\",\n  \"contentEncoding\",\n  \"if\",\n  \"then\",\n  \"else\",\n  \"not\",\n  \"patternProperties\",\n  \"unevaluatedProperties\",\n  \"unevaluatedItems\",\n  \"dependentRequired\",\n  \"dependentSchemas\",\n  \"propertyNames\",\n  \"minContains\",\n  \"maxContains\",\n]);\n\nexport function toGeminiSchema(schema: unknown): unknown {\n  // Return primitives and arrays as-is\n  if (!schema || typeof schema !== \"object\" || Array.isArray(schema)) {\n    return schema;\n  }\n\n  const inputSchema = schema as Record<string, unknown>;\n  const result: Record<string, unknown> = {};\n\n  // First pass: collect all property names for required validation\n  const propertyNames = new Set<string>();\n  if (inputSchema.properties && typeof inputSchema.properties === \"object\") {\n    for (const propName of Object.keys(inputSchema.properties as Record<string, unknown>)) {\n      propertyNames.add(propName);\n    }\n  }\n\n  for (const [key, value] of Object.entries(inputSchema)) {\n    // Skip unsupported fields that Gemini API rejects\n    if (UNSUPPORTED_SCHEMA_FIELDS.has(key)) {\n      continue;\n    }\n\n    if (key === \"type\" && typeof value === \"string\") {\n      // Convert type to uppercase for Gemini API\n      result[key] = value.toUpperCase();\n    } else if (key === \"properties\" && typeof value === \"object\" && value !== null) {\n      // Recursively transform nested property schemas\n      const props: Record<string, unknown> = {};\n      for (const [propName, propSchema] of Object.entries(value as Record<string, unknown>)) {\n        props[propName] = toGeminiSchema(propSchema);\n      }\n      result[key] = props;\n    } else if (key === \"items\" && typeof value === \"object\") {\n      // Transform array items schema\n      result[key] = toGeminiSchema(value);\n    } else if ((key === \"anyOf\" || key === \"oneOf\" || key === \"allOf\") && Array.isArray(value)) {\n      // Transform union type schemas\n      result[key] = value.map((item) => toGeminiSchema(item));\n    } else if (key === \"enum\" && Array.isArray(value)) {\n      // Keep enum values as-is\n      result[key] = value;\n    } else if (key === \"default\" || key === \"examples\") {\n      // Keep default and examples as-is\n      result[key] = value;\n    } else if (key === \"required\" && Array.isArray(value)) {\n      // Filter required array to only include properties that exist\n      // This fixes: \"parameters.required[X]: property is not defined\"\n      if (propertyNames.size > 0) {\n        const validRequired = value.filter((prop) =>\n          typeof prop === \"string\" && propertyNames.has(prop)\n        );\n        if (validRequired.length > 0) {\n          result[key] = validRequired;\n        }\n        // If no valid required properties, omit the required field entirely\n      } else {\n        // If there are no properties, keep required as-is (might be a schema without properties)\n        result[key] = value;\n      }\n    } else {\n      result[key] = value;\n    }\n  }\n\n  // Issue #80: Ensure array schemas have an 'items' field\n  // Gemini API requires: \"parameters.properties[X].items: missing field\"\n  if (result.type === \"ARRAY\" && !result.items) {\n    result.items = { type: \"STRING\" };\n  }\n\n  return result;\n}\n\n/**\n * Check if a model is a Gemini model (not Claude).\n */\nexport function isGeminiModel(model: string): boolean {\n  const lower = model.toLowerCase();\n  return lower.includes(\"gemini\") && !lower.includes(\"claude\");\n}\n\n/**\n * Check if a model is Gemini 3 (uses thinkingLevel string).\n */\nexport function isGemini3Model(model: string): boolean {\n  return model.toLowerCase().includes(\"gemini-3\");\n}\n\n/**\n * Check if a model is Gemini 2.5 (uses numeric thinkingBudget).\n */\nexport function isGemini25Model(model: string): boolean {\n  return model.toLowerCase().includes(\"gemini-2.5\");\n}\n\n/**\n * Check if a model is an image generation model.\n * Image models don't support thinking and require imageConfig.\n */\nexport function isImageGenerationModel(model: string): boolean {\n  const lower = model.toLowerCase();\n  return (\n    lower.includes(\"image\") ||\n    lower.includes(\"imagen\")\n  );\n}\n\n/**\n * Build Gemini 3 thinking config with thinkingLevel string.\n */\nexport function buildGemini3ThinkingConfig(\n  includeThoughts: boolean,\n  thinkingLevel: ThinkingTier,\n): ThinkingConfig {\n  return {\n    includeThoughts,\n    thinkingLevel,\n  };\n}\n\n/**\n * Build Gemini 2.5 thinking config with numeric thinkingBudget.\n */\nexport function buildGemini25ThinkingConfig(\n  includeThoughts: boolean,\n  thinkingBudget?: number,\n): ThinkingConfig {\n  return {\n    includeThoughts,\n    ...(typeof thinkingBudget === \"number\" && thinkingBudget > 0 ? { thinkingBudget } : {}),\n  };\n}\n\n/**\n * Image generation config for Gemini image models.\n * \n * Supported aspect ratios: \"1:1\", \"2:3\", \"3:2\", \"3:4\", \"4:3\", \"4:5\", \"5:4\", \"9:16\", \"16:9\", \"21:9\"\n */\nexport interface ImageConfig {\n  aspectRatio?: string;\n}\n\n/**\n * Valid aspect ratios for image generation.\n */\nconst VALID_ASPECT_RATIOS = [\"1:1\", \"2:3\", \"3:2\", \"3:4\", \"4:3\", \"4:5\", \"5:4\", \"9:16\", \"16:9\", \"21:9\"];\n\n/**\n * Build image generation config for Gemini image models.\n * \n * Configuration is read from environment variables:\n * - OPENCODE_IMAGE_ASPECT_RATIO: Aspect ratio (e.g., \"16:9\", \"4:3\")\n * \n * Defaults to 1:1 aspect ratio if not specified.\n * \n * Note: Resolution setting is not currently supported by the Antigravity API.\n */\nexport function buildImageGenerationConfig(): ImageConfig {\n  // Read aspect ratio from environment or default to 1:1\n  const aspectRatio = process.env.OPENCODE_IMAGE_ASPECT_RATIO || \"1:1\";\n\n  if (VALID_ASPECT_RATIOS.includes(aspectRatio)) {\n    return { aspectRatio };\n  }\n\n  console.warn(`[gemini] Invalid aspect ratio \"${aspectRatio}\". Using default \"1:1\". Valid values: ${VALID_ASPECT_RATIOS.join(\", \")}`);\n\n  // Default to 1:1 square aspect ratio\n  return { aspectRatio: \"1:1\" };\n}\n\n/**\n * Normalize tools for Gemini models.\n * Ensures tools have proper function-style format.\n * \n * @returns Debug info about tool normalization\n */\nexport function normalizeGeminiTools(\n  payload: RequestPayload,\n): { toolDebugMissing: number; toolDebugSummaries: string[] } {\n  let toolDebugMissing = 0;\n  const toolDebugSummaries: string[] = [];\n\n  if (!Array.isArray(payload.tools)) {\n    return { toolDebugMissing, toolDebugSummaries };\n  }\n\n  payload.tools = (payload.tools as unknown[]).map((tool: unknown, toolIndex: number) => {\n    const t = tool as Record<string, unknown>;\n\n    // Skip normalization for Google Search tools (both old and new API)\n    if (t.googleSearch || t.googleSearchRetrieval) {\n      return t;\n    }\n\n    const newTool = { ...t };\n\n    const schemaCandidates = [\n      (newTool.function as Record<string, unknown> | undefined)?.input_schema,\n      (newTool.function as Record<string, unknown> | undefined)?.parameters,\n      (newTool.function as Record<string, unknown> | undefined)?.inputSchema,\n      (newTool.custom as Record<string, unknown> | undefined)?.input_schema,\n      (newTool.custom as Record<string, unknown> | undefined)?.parameters,\n      newTool.parameters,\n      newTool.input_schema,\n      newTool.inputSchema,\n    ].filter(Boolean);\n\n    const placeholderSchema: Record<string, unknown> = {\n      type: \"OBJECT\",\n      properties: {\n        _placeholder: {\n          type: \"BOOLEAN\",\n          description: \"Placeholder. Always pass true.\",\n        },\n      },\n      required: [\"_placeholder\"],\n    };\n\n    let schema = schemaCandidates[0] as Record<string, unknown> | undefined;\n    const schemaObjectOk = schema && typeof schema === \"object\" && !Array.isArray(schema);\n    if (!schemaObjectOk) {\n      schema = placeholderSchema;\n      toolDebugMissing += 1;\n    } else {\n      // Transform existing schema to Gemini-compatible format\n      schema = toGeminiSchema(schema) as Record<string, unknown>;\n    }\n\n    const nameCandidate =\n      newTool.name ||\n      (newTool.function as Record<string, unknown> | undefined)?.name ||\n      (newTool.custom as Record<string, unknown> | undefined)?.name ||\n      `tool-${toolIndex}`;\n\n    // Always update function.input_schema with transformed schema\n    if (newTool.function && schema) {\n      (newTool.function as Record<string, unknown>).input_schema = schema;\n    }\n\n    // Always update custom.input_schema with transformed schema\n    if (newTool.custom && schema) {\n      (newTool.custom as Record<string, unknown>).input_schema = schema;\n    }\n\n    // Create custom from function if missing\n    if (!newTool.custom && newTool.function) {\n      const fn = newTool.function as Record<string, unknown>;\n      newTool.custom = {\n        name: fn.name || nameCandidate,\n        description: fn.description,\n        input_schema: schema,\n      };\n    }\n\n    // Create custom if both missing\n    if (!newTool.custom && !newTool.function) {\n      newTool.custom = {\n        name: nameCandidate,\n        description: newTool.description,\n        input_schema: schema,\n      };\n\n      if (!newTool.parameters && !newTool.input_schema && !newTool.inputSchema) {\n        newTool.parameters = schema;\n      }\n    }\n\n    if (newTool.custom && !(newTool.custom as Record<string, unknown>).input_schema) {\n      (newTool.custom as Record<string, unknown>).input_schema = {\n        type: \"OBJECT\",\n        properties: {},\n      };\n      toolDebugMissing += 1;\n    }\n\n    toolDebugSummaries.push(\n      `idx=${toolIndex}, hasCustom=${!!newTool.custom}, customSchema=${!!(newTool.custom as Record<string, unknown> | undefined)?.input_schema}, hasFunction=${!!newTool.function}, functionSchema=${!!(newTool.function as Record<string, unknown> | undefined)?.input_schema}`,\n    );\n\n    // Strip custom wrappers for Gemini; only function-style is accepted.\n    if (newTool.custom) {\n      delete newTool.custom;\n    }\n\n    return newTool;\n  });\n\n  return { toolDebugMissing, toolDebugSummaries };\n}\n\n/**\n * Apply all Gemini-specific transformations to a request payload.\n */\nexport interface GeminiTransformOptions {\n  /** The effective model name (resolved) */\n  model: string;\n  /** Tier-based thinking budget (from model suffix, for Gemini 2.5) */\n  tierThinkingBudget?: number;\n  /** Tier-based thinking level (from model suffix, for Gemini 3) */\n  tierThinkingLevel?: ThinkingTier;\n  /** Normalized thinking config from user settings */\n  normalizedThinking?: { includeThoughts?: boolean; thinkingBudget?: number };\n  /** Google Search configuration */\n  googleSearch?: GoogleSearchConfig;\n}\n\nexport interface GeminiTransformResult {\n  toolDebugMissing: number;\n  toolDebugSummaries: string[];\n  /** Number of function declarations after wrapping */\n  wrappedFunctionCount: number;\n  /** Number of passthrough tools (googleSearch, googleSearchRetrieval, codeExecution) */\n  passthroughToolCount: number;\n}\n\n/**\n * Apply all Gemini-specific transformations.\n */\nexport function applyGeminiTransforms(\n  payload: RequestPayload,\n  options: GeminiTransformOptions,\n): GeminiTransformResult {\n  const { model, tierThinkingBudget, tierThinkingLevel, normalizedThinking, googleSearch } = options;\n\n  // 1. Apply thinking config if needed\n  if (normalizedThinking) {\n    let thinkingConfig: ThinkingConfig;\n\n    if (tierThinkingLevel && isGemini3Model(model)) {\n      // Gemini 3 uses thinkingLevel string\n      thinkingConfig = buildGemini3ThinkingConfig(\n        normalizedThinking.includeThoughts ?? true,\n        tierThinkingLevel,\n      );\n    } else {\n      // Gemini 2.5 and others use numeric budget\n      const thinkingBudget = tierThinkingBudget ?? normalizedThinking.thinkingBudget;\n      thinkingConfig = buildGemini25ThinkingConfig(\n        normalizedThinking.includeThoughts ?? true,\n        thinkingBudget,\n      );\n    }\n\n    const generationConfig = (payload.generationConfig ?? {}) as Record<string, unknown>;\n    generationConfig.thinkingConfig = thinkingConfig;\n    payload.generationConfig = generationConfig;\n  }\n\n  // 2. Apply Google Search (Grounding) if enabled\n  // Uses the new googleSearch API for Gemini 2.0+ / Gemini 3 models\n  // Note: The old googleSearchRetrieval with dynamicRetrievalConfig is deprecated\n  // The new API doesn't support threshold - the model decides when to search automatically\n  if (googleSearch && googleSearch.mode === 'auto') {\n    const tools = (payload.tools as unknown[]) || [];\n    if (!payload.tools) {\n      payload.tools = tools;\n    }\n\n    // Add Google Search tool using new API format for Gemini 2.0+\n    // See: https://ai.google.dev/gemini-api/docs/grounding\n    (payload.tools as any[]).push({\n      googleSearch: {},\n    });\n  }\n\n  // 3. Normalize tools\n  const result = normalizeGeminiTools(payload);\n\n  // 4. Wrap tools in functionDeclarations format (fixes #203, #206)\n  // Antigravity strict protobuf validation rejects wrapper-level 'parameters' field\n  // Must be: [{ functionDeclarations: [{ name, description, parameters }] }]\n  const wrapResult = wrapToolsAsFunctionDeclarations(payload);\n\n  return {\n    ...result,\n    wrappedFunctionCount: wrapResult.wrappedFunctionCount,\n    passthroughToolCount: wrapResult.passthroughToolCount,\n  };\n}\n\nexport interface WrapToolsResult {\n  wrappedFunctionCount: number;\n  passthroughToolCount: number;\n}\n\n/**\n * Wrap tools array in Gemini's required functionDeclarations format.\n * \n * Gemini/Antigravity API expects:\n *   { tools: [{ functionDeclarations: [{ name, description, parameters }] }] }\n * \n * NOT:\n *   { tools: [{ function: {...}, parameters: {...} }] }\n * \n * The wrapper-level 'parameters' field causes:\n *   \"Unknown name 'parameters' at 'request.tools[0]'\"\n */\n/**\n * Detect if a tool is a web search tool in any of the supported formats:\n * - Claude/Anthropic: { type: \"web_search_20250305\" } or { name: \"web_search\" }\n * - Gemini native: { googleSearch: {} } or { googleSearchRetrieval: {} }\n */\nfunction isWebSearchTool(tool: Record<string, unknown>): boolean {\n  // 1. Gemini native format\n  if (tool.googleSearch || tool.googleSearchRetrieval) {\n    return true;\n  }\n\n  // 2. Claude/Anthropic format: { type: \"web_search_20250305\" }\n  if (tool.type === \"web_search_20250305\") {\n    return true;\n  }\n\n  // 3. Simple name-based format: { name: \"web_search\" | \"google_search\" }\n  const name = tool.name as string | undefined;\n  if (name === \"web_search\" || name === \"google_search\") {\n    return true;\n  }\n\n  return false;\n}\n\nexport function wrapToolsAsFunctionDeclarations(payload: RequestPayload): WrapToolsResult {\n  if (!Array.isArray(payload.tools) || payload.tools.length === 0) {\n    return { wrappedFunctionCount: 0, passthroughToolCount: 0 };\n  }\n\n  const functionDeclarations: Array<{\n    name: string;\n    description: string;\n    parameters: Record<string, unknown>;\n  }> = [];\n\n  const passthroughTools: unknown[] = [];\n  let hasWebSearchTool = false;\n\n  for (const tool of payload.tools as Array<Record<string, unknown>>) {\n    // Handle passthrough tools (Google Search and Code Execution)\n    if (tool.googleSearch || tool.googleSearchRetrieval || tool.codeExecution) {\n      passthroughTools.push(tool);\n      continue;\n    }\n\n    // Detect and convert web search tools to Gemini format\n    if (isWebSearchTool(tool)) {\n      hasWebSearchTool = true;\n      continue; // Will be added as { googleSearch: {} } at the end\n    }\n\n    if (tool.functionDeclarations) {\n      if (Array.isArray(tool.functionDeclarations)) {\n        for (const decl of tool.functionDeclarations as Array<Record<string, unknown>>) {\n          functionDeclarations.push({\n            name: String(decl.name || `tool-${functionDeclarations.length}`),\n            description: String(decl.description || \"\"),\n            parameters: (decl.parameters as Record<string, unknown>) || { type: \"OBJECT\", properties: {} },\n          });\n        }\n      }\n      continue;\n    }\n\n    const fn = tool.function as Record<string, unknown> | undefined;\n    const custom = tool.custom as Record<string, unknown> | undefined;\n\n    const name = String(\n      tool.name ||\n      fn?.name ||\n      custom?.name ||\n      `tool-${functionDeclarations.length}`\n    );\n\n    const description = String(\n      tool.description ||\n      fn?.description ||\n      custom?.description ||\n      \"\"\n    );\n\n    const schema = (\n      fn?.input_schema ||\n      fn?.parameters ||\n      fn?.inputSchema ||\n      custom?.input_schema ||\n      custom?.parameters ||\n      tool.parameters ||\n      tool.input_schema ||\n      tool.inputSchema ||\n      { type: \"OBJECT\", properties: {} }\n    ) as Record<string, unknown>;\n\n    functionDeclarations.push({\n      name,\n      description,\n      parameters: schema,\n    });\n  }\n\n  const finalTools: unknown[] = [];\n\n  if (functionDeclarations.length > 0) {\n    finalTools.push({ functionDeclarations });\n  }\n\n  finalTools.push(...passthroughTools);\n\n  // Add googleSearch tool if a web search tool was detected\n  // Note: googleSearch cannot be combined with functionDeclarations in the same request\n  // If there are function declarations, we skip adding googleSearch (Gemini API limitation)\n  if (hasWebSearchTool && functionDeclarations.length === 0) {\n    finalTools.push({ googleSearch: {} });\n  } else if (hasWebSearchTool && functionDeclarations.length > 0) {\n    // Log warning: web search requested but can't be used with functions\n    console.warn(\n      \"[gemini] web_search tool detected but cannot be combined with function declarations. \" +\n      \"Use the explicit google_search() tool call instead.\"\n    );\n  }\n\n  payload.tools = finalTools;\n\n  return {\n    wrappedFunctionCount: functionDeclarations.length,\n    passthroughToolCount: passthroughTools.length + (hasWebSearchTool && functionDeclarations.length === 0 ? 1 : 0),\n  };\n}\n"
  },
  {
    "path": "src/plugin/transform/index.ts",
    "content": "/**\n * Transform Module Index\n * \n * Re-exports transform functions and types for request transformation.\n */\n\n// Types\nexport type {\n  ModelFamily,\n  ThinkingTier,\n  TransformContext,\n  TransformResult,\n  TransformDebugInfo,\n  RequestPayload,\n  ThinkingConfig,\n  ResolvedModel,\n  GoogleSearchConfig,\n} from \"./types\";\n\n// Model resolution\nexport {\n  resolveModelWithTier,\n  resolveModelWithVariant,\n  resolveModelForHeaderStyle,\n  getModelFamily,\n  MODEL_ALIASES,\n  THINKING_TIER_BUDGETS,\n  GEMINI_3_THINKING_LEVELS,\n} from \"./model-resolver\";\nexport type { VariantConfig } from \"./model-resolver\";\n\n// Claude transforms\nexport {\n  isClaudeModel,\n  isClaudeThinkingModel,\n  configureClaudeToolConfig,\n  buildClaudeThinkingConfig,\n  ensureClaudeMaxOutputTokens,\n  appendClaudeThinkingHint,\n  normalizeClaudeTools,\n  applyClaudeTransforms,\n  CLAUDE_THINKING_MAX_OUTPUT_TOKENS,\n  CLAUDE_INTERLEAVED_THINKING_HINT,\n} from \"./claude\";\nexport type { ClaudeTransformOptions, ClaudeTransformResult } from \"./claude\";\n\n// Gemini transforms\nexport {\n  isGeminiModel,\n  isGemini3Model,\n  isGemini25Model,\n  isImageGenerationModel,\n  buildGemini3ThinkingConfig,\n  buildGemini25ThinkingConfig,\n  buildImageGenerationConfig,\n  normalizeGeminiTools,\n  applyGeminiTransforms,\n} from \"./gemini\";\nexport type { GeminiTransformOptions, GeminiTransformResult, ImageConfig } from \"./gemini\";\n\n// Cross-model sanitization\nexport {\n  sanitizeCrossModelPayload,\n  sanitizeCrossModelPayloadInPlace,\n  getModelFamily as getCrossModelFamily,\n  stripGeminiThinkingMetadata,\n  stripClaudeThinkingFields,\n} from \"./cross-model-sanitizer\";\nexport type { SanitizerOptions } from \"./cross-model-sanitizer\";\n"
  },
  {
    "path": "src/plugin/transform/model-resolver.test.ts",
    "content": "import { describe, it, expect } from \"vitest\";\nimport { resolveModelWithTier, resolveModelWithVariant, resolveModelForHeaderStyle } from \"./model-resolver\";\n\ndescribe(\"resolveModelWithTier\", () => {\n  describe(\"Gemini 3 flash models (Issue #109)\", () => {\n    it(\"antigravity-gemini-3-flash gets default thinkingLevel 'low'\", () => {\n      const result = resolveModelWithTier(\"antigravity-gemini-3-flash\");\n      expect(result.actualModel).toBe(\"gemini-3-flash\");\n      expect(result.thinkingLevel).toBe(\"low\");\n      expect(result.quotaPreference).toBe(\"antigravity\");\n    });\n\n    it(\"gemini-3-flash gets default thinkingLevel 'low'\", () => {\n      const result = resolveModelWithTier(\"gemini-3-flash\");\n      expect(result.actualModel).toBe(\"gemini-3-flash\");\n      expect(result.thinkingLevel).toBe(\"low\");\n      expect(result.quotaPreference).toBe(\"antigravity\");\n    });\n\n    it(\"gemini-3-flash-preview gets default thinkingLevel 'low' with antigravity quota\", () => {\n      const result = resolveModelWithTier(\"gemini-3-flash-preview\");\n      expect(result.actualModel).toBe(\"gemini-3-flash-preview\");\n      expect(result.thinkingLevel).toBe(\"low\");\n      // All Gemini models now default to antigravity\n      expect(result.quotaPreference).toBe(\"antigravity\");\n    });\n  });\n\n  describe(\"Gemini 3 preview models (Issue #115)\", () => {\n    it(\"gemini-3-pro-preview gets default thinkingLevel 'low' with antigravity quota\", () => {\n      const result = resolveModelWithTier(\"gemini-3-pro-preview\");\n      expect(result.actualModel).toBe(\"gemini-3-pro-preview\");\n      expect(result.thinkingLevel).toBe(\"low\");\n      // All Gemini models now default to antigravity\n      expect(result.quotaPreference).toBe(\"antigravity\");\n    });\n\n    it(\"gemini-3.1-pro-preview gets default thinkingLevel 'low' with antigravity quota\", () => {\n      const result = resolveModelWithTier(\"gemini-3.1-pro-preview\");\n      expect(result.actualModel).toBe(\"gemini-3.1-pro-preview\");\n      expect(result.thinkingLevel).toBe(\"low\");\n      expect(result.quotaPreference).toBe(\"antigravity\");\n    });\n  });\n\n  describe(\"All Gemini models default to antigravity quota\", () => {\n    it(\"gemini-2.5-flash defaults to antigravity\", () => {\n      const result = resolveModelWithTier(\"gemini-2.5-flash\");\n      expect(result.quotaPreference).toBe(\"antigravity\");\n    });\n\n    it(\"gemini-2.5-pro defaults to antigravity\", () => {\n      const result = resolveModelWithTier(\"gemini-2.5-pro\");\n      expect(result.quotaPreference).toBe(\"antigravity\");\n    });\n\n    it(\"gemini-2.0-flash defaults to antigravity\", () => {\n      const result = resolveModelWithTier(\"gemini-2.0-flash\");\n      expect(result.quotaPreference).toBe(\"antigravity\");\n    });\n  });\n\n  describe(\"cli_first quota preference\", () => {\n    it(\"prefers gemini-cli when cli_first is true and no prefix is set\", () => {\n      const result = resolveModelWithTier(\"gemini-3-flash\", { cli_first: true });\n      expect(result.quotaPreference).toBe(\"gemini-cli\");\n      expect(result.explicitQuota).toBe(false);\n    });\n\n    it(\"keeps antigravity when antigravity prefix is explicit\", () => {\n      const result = resolveModelWithTier(\"antigravity-gemini-3-flash\", { cli_first: true });\n      expect(result.quotaPreference).toBe(\"antigravity\");\n      expect(result.explicitQuota).toBe(true);\n    });\n\n    it(\"keeps antigravity for Claude models when cli_first is true\", () => {\n      const result = resolveModelWithTier(\"claude-opus-4-6-thinking\", { cli_first: true });\n      expect(result.quotaPreference).toBe(\"antigravity\");\n    });\n\n    it(\"keeps antigravity for image models when cli_first is true\", () => {\n      const result = resolveModelWithTier(\"gemini-3-pro-image\", { cli_first: true });\n      expect(result.quotaPreference).toBe(\"antigravity\");\n      expect(result.explicitQuota).toBe(true);\n    });\n\n    it(\"defaults to antigravity when cli_first is false\", () => {\n      const result = resolveModelWithTier(\"gemini-3-flash\", { cli_first: false });\n      expect(result.quotaPreference).toBe(\"antigravity\");\n    });\n  });\n\n  describe(\"Antigravity Gemini 3 with tier suffix\", () => {\n    it(\"antigravity-gemini-3-pro-low gets thinkingLevel from tier\", () => {\n      const result = resolveModelWithTier(\"antigravity-gemini-3-pro-low\");\n      expect(result.actualModel).toBe(\"gemini-3-pro-low\");\n      expect(result.thinkingLevel).toBe(\"low\");\n      expect(result.quotaPreference).toBe(\"antigravity\");\n    });\n\n    it(\"antigravity-gemini-3-pro-high gets thinkingLevel from tier\", () => {\n      const result = resolveModelWithTier(\"antigravity-gemini-3-pro-high\");\n      expect(result.actualModel).toBe(\"gemini-3-pro-high\");\n      expect(result.thinkingLevel).toBe(\"high\");\n      expect(result.quotaPreference).toBe(\"antigravity\");\n    });\n\n    it(\"antigravity-gemini-3-flash-medium gets thinkingLevel from tier\", () => {\n      const result = resolveModelWithTier(\"antigravity-gemini-3-flash-medium\");\n      expect(result.actualModel).toBe(\"gemini-3-flash\");\n      expect(result.thinkingLevel).toBe(\"medium\");\n    });\n\n    it(\"antigravity-gemini-3.1-pro gets default -low model\", () => {\n      const result = resolveModelWithTier(\"antigravity-gemini-3.1-pro\");\n      expect(result.actualModel).toBe(\"gemini-3.1-pro-low\");\n      expect(result.thinkingLevel).toBe(\"low\");\n    });\n  });\n\n  describe(\"Claude thinking models default budget\", () => {\n    it(\"antigravity-claude-opus-4-6-thinking gets default max budget (32768)\", () => {\n      const result = resolveModelWithTier(\"antigravity-claude-opus-4-6-thinking\");\n      expect(result.actualModel).toBe(\"claude-opus-4-6-thinking\");\n      expect(result.thinkingBudget).toBe(32768);\n      expect(result.isThinkingModel).toBe(true);\n      expect(result.quotaPreference).toBe(\"antigravity\");\n    });\n  });\n\n  describe(\"Claude Sonnet 4.6 (non-thinking)\", () => {\n    it(\"claude-sonnet-4-6 resolves as non-thinking model\", () => {\n      const result = resolveModelWithTier(\"claude-sonnet-4-6\");\n      expect(result.actualModel).toBe(\"claude-sonnet-4-6\");\n      expect(result.isThinkingModel).toBe(false);\n      expect(result.thinkingBudget).toBeUndefined();\n      expect(result.quotaPreference).toBe(\"antigravity\");\n    });\n\n    it(\"antigravity-claude-sonnet-4-6 resolves as non-thinking model with explicit quota\", () => {\n      const result = resolveModelWithTier(\"antigravity-claude-sonnet-4-6\");\n      expect(result.actualModel).toBe(\"claude-sonnet-4-6\");\n      expect(result.isThinkingModel).toBe(false);\n      expect(result.thinkingBudget).toBeUndefined();\n      expect(result.quotaPreference).toBe(\"antigravity\");\n      expect(result.explicitQuota).toBe(true);\n    });\n\n    it(\"gemini-claude-sonnet-4-6 alias resolves to claude-sonnet-4-6\", () => {\n      const result = resolveModelWithTier(\"gemini-claude-sonnet-4-6\");\n      expect(result.actualModel).toBe(\"claude-sonnet-4-6\");\n      expect(result.isThinkingModel).toBe(false);\n      expect(result.quotaPreference).toBe(\"antigravity\");\n    });\n  });\n\n  describe(\"Image models\", () => {\n    it(\"marks antigravity-gemini-3-pro-image as explicit quota\", () => {\n      const result = resolveModelWithTier(\"antigravity-gemini-3-pro-image\");\n      expect(result.actualModel).toBe(\"gemini-3-pro-image\");\n      expect(result.isImageModel).toBe(true);\n      expect(result.explicitQuota).toBe(true);\n      expect(result.quotaPreference).toBe(\"antigravity\");\n    });\n\n    it(\"marks gemini-3-pro-image as explicit quota\", () => {\n      const result = resolveModelWithTier(\"gemini-3-pro-image\");\n      expect(result.actualModel).toBe(\"gemini-3-pro-image\");\n      expect(result.isImageModel).toBe(true);\n      expect(result.explicitQuota).toBe(true);\n      expect(result.quotaPreference).toBe(\"antigravity\");\n    });\n  });\n});\n\ndescribe(\"resolveModelWithVariant\", () => {\n  describe(\"without variant config\", () => {\n    it(\"falls back to tier resolution for Claude thinking models\", () => {\n      const result = resolveModelWithVariant(\"claude-opus-4-6-thinking-low\");\n      expect(result.actualModel).toBe(\"claude-opus-4-6-thinking\");\n      expect(result.thinkingBudget).toBe(8192);\n      expect(result.configSource).toBeUndefined();\n    });\n\n    it(\"falls back to tier resolution for Gemini 3 models\", () => {\n      const result = resolveModelWithVariant(\"gemini-3-pro-high\");\n      expect(result.actualModel).toBe(\"gemini-3-pro\");\n      expect(result.thinkingLevel).toBe(\"high\");\n      expect(result.configSource).toBeUndefined();\n    });\n  });\n\n  describe(\"with variant config\", () => {\n    it(\"overrides tier budget for Claude models\", () => {\n      const result = resolveModelWithVariant(\"antigravity-claude-opus-4-6-thinking\", {\n        thinkingBudget: 24000,\n      });\n      expect(result.actualModel).toBe(\"claude-opus-4-6-thinking\");\n      expect(result.thinkingBudget).toBe(24000);\n      expect(result.configSource).toBe(\"variant\");\n    });\n\n    it(\"maps budget to thinkingLevel for Gemini 3 - low\", () => {\n      const result = resolveModelWithVariant(\"antigravity-gemini-3-pro\", {\n        thinkingBudget: 8000,\n      });\n      expect(result.actualModel).toBe(\"gemini-3-pro-low\");\n      expect(result.thinkingLevel).toBe(\"low\");\n      expect(result.thinkingBudget).toBeUndefined();\n      expect(result.configSource).toBe(\"variant\");\n    });\n\n    it(\"maps budget to thinkingLevel for Gemini 3 Flash - medium (no tier suffix)\", () => {\n      const result = resolveModelWithVariant(\"antigravity-gemini-3-flash\", {\n        thinkingBudget: 12000,\n      });\n      expect(result.actualModel).toBe(\"gemini-3-flash\");\n      expect(result.thinkingLevel).toBe(\"medium\");\n      expect(result.configSource).toBe(\"variant\");\n    });\n\n    it(\"maps budget to thinkingLevel for Gemini 3 - high\", () => {\n      const result = resolveModelWithVariant(\"antigravity-gemini-3-pro\", {\n        thinkingBudget: 32000,\n      });\n      expect(result.thinkingLevel).toBe(\"high\");\n      expect(result.configSource).toBe(\"variant\");\n    });\n\n    it(\"uses budget directly for non-Gemini 3 models\", () => {\n      const result = resolveModelWithVariant(\"gemini-2.5-pro\", {\n        thinkingBudget: 20000,\n      });\n      expect(result.thinkingBudget).toBe(20000);\n      expect(result.thinkingLevel).toBeUndefined();\n      expect(result.configSource).toBe(\"variant\");\n    });\n  });\n\n  describe(\"backward compatibility\", () => {\n    it(\"tier-suffixed models work without variant config\", () => {\n      const lowResult = resolveModelWithVariant(\"claude-opus-4-6-thinking-low\");\n      expect(lowResult.thinkingBudget).toBe(8192);\n\n      const medResult = resolveModelWithVariant(\"claude-opus-4-6-thinking-medium\");\n      expect(medResult.thinkingBudget).toBe(16384);\n\n      const highResult = resolveModelWithVariant(\"claude-opus-4-6-thinking-high\");\n      expect(highResult.thinkingBudget).toBe(32768);\n    });\n\n    it(\"variant config overrides tier suffix\", () => {\n      const result = resolveModelWithVariant(\"claude-opus-4-6-thinking-low\", {\n        thinkingBudget: 50000,\n      });\n      expect(result.thinkingBudget).toBe(50000);\n      expect(result.configSource).toBe(\"variant\");\n    });\n  });\n});\n\ndescribe(\"Issue #103: resolveModelForHeaderStyle\", () => {\n  describe(\"quota fallback from gemini-cli to antigravity\", () => {\n    it(\"transforms gemini-3-flash-preview to gemini-3-flash for antigravity\", () => {\n      const result = resolveModelForHeaderStyle(\"gemini-3-flash-preview\", \"antigravity\");\n      expect(result.actualModel).toBe(\"gemini-3-flash\");\n      expect(result.quotaPreference).toBe(\"antigravity\");\n    });\n\n    it(\"transforms gemini-3-pro-preview to gemini-3-pro-low for antigravity\", () => {\n      const result = resolveModelForHeaderStyle(\"gemini-3-pro-preview\", \"antigravity\");\n      expect(result.actualModel).toBe(\"gemini-3-pro-low\");\n      expect(result.quotaPreference).toBe(\"antigravity\");\n    });\n\n    it(\"transforms gemini-3.1-pro-preview to gemini-3.1-pro-low for antigravity\", () => {\n      const result = resolveModelForHeaderStyle(\"gemini-3.1-pro-preview\", \"antigravity\");\n      expect(result.actualModel).toBe(\"gemini-3.1-pro-low\");\n      expect(result.quotaPreference).toBe(\"antigravity\");\n    });\n\n    it(\"transforms gemini-3.1-pro-preview-customtools to gemini-3.1-pro-low for antigravity\", () => {\n      const result = resolveModelForHeaderStyle(\"gemini-3.1-pro-preview-customtools\", \"antigravity\");\n      expect(result.actualModel).toBe(\"gemini-3.1-pro-low\");\n      expect(result.quotaPreference).toBe(\"antigravity\");\n    });\n  });\n\n  describe(\"quota fallback from antigravity to gemini-cli\", () => {\n    it(\"transforms gemini-3-flash to gemini-3-flash-preview for gemini-cli\", () => {\n      const result = resolveModelForHeaderStyle(\"gemini-3-flash\", \"gemini-cli\");\n      expect(result.actualModel).toBe(\"gemini-3-flash-preview\");\n      expect(result.quotaPreference).toBe(\"gemini-cli\");\n    });\n\n    it(\"transforms gemini-3-pro-low to gemini-3-pro-preview for gemini-cli\", () => {\n      const result = resolveModelForHeaderStyle(\"gemini-3-pro-low\", \"gemini-cli\");\n      expect(result.actualModel).toBe(\"gemini-3-pro-preview\");\n      expect(result.quotaPreference).toBe(\"gemini-cli\");\n    });\n\n    it(\"transforms gemini-3.1-pro-low to gemini-3.1-pro-preview for gemini-cli\", () => {\n      const result = resolveModelForHeaderStyle(\"gemini-3.1-pro-low\", \"gemini-cli\");\n      expect(result.actualModel).toBe(\"gemini-3.1-pro-preview\");\n      expect(result.quotaPreference).toBe(\"gemini-cli\");\n    });\n\n    it(\"keeps gemini-3.1-pro-preview-customtools unchanged for gemini-cli\", () => {\n      const result = resolveModelForHeaderStyle(\"gemini-3.1-pro-preview-customtools\", \"gemini-cli\");\n      expect(result.actualModel).toBe(\"gemini-3.1-pro-preview-customtools\");\n      expect(result.quotaPreference).toBe(\"gemini-cli\");\n    });\n  });\n\n  describe(\"no transformation needed\", () => {\n    it(\"keeps gemini-2.5-flash unchanged for both header styles\", () => {\n      const antigravity = resolveModelForHeaderStyle(\"gemini-2.5-flash\", \"antigravity\");\n      const cli = resolveModelForHeaderStyle(\"gemini-2.5-flash\", \"gemini-cli\");\n      expect(antigravity.actualModel).toBe(\"gemini-2.5-flash\");\n      expect(cli.actualModel).toBe(\"gemini-2.5-flash\");\n    });\n\n    it(\"keeps claude models unchanged (antigravity only)\", () => {\n      const result = resolveModelForHeaderStyle(\"claude-opus-4-6-thinking\", \"antigravity\");\n      expect(result.actualModel).toBe(\"claude-opus-4-6-thinking\");\n    });\n  });\n});\n"
  },
  {
    "path": "src/plugin/transform/model-resolver.ts",
    "content": "/**\n * Model Resolution with Thinking Tier Support\n * \n * Resolves model names with tier suffixes (e.g., gemini-3-pro-high, claude-opus-4-6-thinking-low)\n * to their actual API model names and corresponding thinking configurations.\n */\n\nimport type { ResolvedModel, ThinkingTier, GoogleSearchConfig } from \"./types\";\n\nexport interface ModelResolverOptions {\n  cli_first?: boolean;\n}\n\n/**\n * Thinking tier budgets by model family.\n * Claude and Gemini 2.5 Pro use numeric budgets.\n */\nexport const THINKING_TIER_BUDGETS = {\n  claude: { low: 8192, medium: 16384, high: 32768 },\n  \"gemini-2.5-pro\": { low: 8192, medium: 16384, high: 32768 },\n  \"gemini-2.5-flash\": { low: 6144, medium: 12288, high: 24576 },\n  default: { low: 4096, medium: 8192, high: 16384 },\n} as const;\n\n/**\n * Gemini 3 uses thinkingLevel strings instead of numeric budgets.\n * Flash supports: minimal, low, medium, high\n * Pro supports: low, high (no minimal/medium)\n */\nexport const GEMINI_3_THINKING_LEVELS = [\"minimal\", \"low\", \"medium\", \"high\"] as const;\n\n/**\n * Model aliases - maps user-friendly names to API model names.\n * \n * Format:\n * - Gemini 3 Pro variants: gemini-3-pro-{low,medium,high}\n * - Claude thinking variants: claude-{model}-thinking-{low,medium,high}\n * - Claude non-thinking: claude-{model} (no -thinking suffix)\n */\nexport const MODEL_ALIASES: Record<string, string> = {\n  // Gemini 3 variants - for Gemini CLI only (tier stripped, thinkingLevel used)\n  // For Antigravity, these are bypassed and full model name is kept\n  \"gemini-3-pro-low\": \"gemini-3-pro\",\n  \"gemini-3-pro-high\": \"gemini-3-pro\",\n  \"gemini-3.1-pro-low\": \"gemini-3.1-pro\",\n  \"gemini-3.1-pro-high\": \"gemini-3.1-pro\",\n  \"gemini-3-flash-low\": \"gemini-3-flash\",\n  \"gemini-3-flash-medium\": \"gemini-3-flash\",\n  \"gemini-3-flash-high\": \"gemini-3-flash\",\n\n  // Claude proxy names (gemini- prefix for compatibility)\n  \"gemini-claude-opus-4-6-thinking-low\": \"claude-opus-4-6-thinking\",\n  \"gemini-claude-opus-4-6-thinking-medium\": \"claude-opus-4-6-thinking\",\n  \"gemini-claude-opus-4-6-thinking-high\": \"claude-opus-4-6-thinking\",\n  \"gemini-claude-sonnet-4-6\": \"claude-sonnet-4-6\",\n\n  // Image generation models - only gemini-3-pro-image is available via Antigravity API\n  // Note: gemini-2.5-flash-image (Nano Banana) is NOT supported by Antigravity - only Google AI API\n  // Reference: Antigravity-Manager/src-tauri/src/proxy/common/model_mapping.rs\n};\n\nconst TIER_REGEX = /-(minimal|low|medium|high)$/;\nconst QUOTA_PREFIX_REGEX = /^antigravity-/i;\nconst GEMINI_3_PRO_REGEX = /^gemini-3(?:\\.\\d+)?-pro/i;\nconst GEMINI_3_FLASH_REGEX = /^gemini-3(?:\\.\\d+)?-flash/i;\n\n// ANTIGRAVITY_ONLY_MODELS removed - all models now default to antigravity\n\n/**\n * Image generation models - always route to Antigravity.\n * These models don't support thinking and require imageConfig.\n */\nconst IMAGE_GENERATION_MODELS = /image|imagen/i;\n\n// Legacy LEGACY_ANTIGRAVITY_GEMINI3 regex removed - all Gemini models now default to antigravity\n\n/**\n * Models that support thinking tier suffixes.\n * Only these models should have -low/-medium/-high stripped as thinking tiers.\n * GPT models like gpt-oss-120b-medium should NOT have -medium stripped.\n */\nfunction supportsThinkingTiers(model: string): boolean {\n  const lower = model.toLowerCase();\n  return (\n    lower.includes(\"gemini-3\") ||\n    lower.includes(\"gemini-2.5\") ||\n    (lower.includes(\"claude\") && lower.includes(\"thinking\"))\n  );\n}\n\n/**\n * Extracts thinking tier from model name suffix.\n * Only extracts tier for models that support thinking tiers.\n */\nfunction extractThinkingTierFromModel(model: string): ThinkingTier | undefined {\n  // Only extract tier for models that support thinking tiers\n  if (!supportsThinkingTiers(model)) {\n    return undefined;\n  }\n  const tierMatch = model.match(TIER_REGEX);\n  return tierMatch?.[1] as ThinkingTier | undefined;\n}\n\n/**\n * Determines the budget family for a model.\n */\nfunction getBudgetFamily(model: string): keyof typeof THINKING_TIER_BUDGETS {\n  if (model.includes(\"claude\")) {\n    return \"claude\";\n  }\n  if (model.includes(\"gemini-2.5-pro\")) {\n    return \"gemini-2.5-pro\";\n  }\n  if (model.includes(\"gemini-2.5-flash\")) {\n    return \"gemini-2.5-flash\";\n  }\n  return \"default\";\n}\n\n/**\n * Checks if a model is a thinking-capable model.\n */\nfunction isThinkingCapableModel(model: string): boolean {\n  const lower = model.toLowerCase();\n  return (\n    lower.includes(\"thinking\") ||\n    lower.includes(\"gemini-3\") ||\n    lower.includes(\"gemini-2.5\")\n  );\n}\n\nfunction isGemini3ProModel(model: string): boolean {\n  return GEMINI_3_PRO_REGEX.test(model);\n}\n\nfunction isGemini3FlashModel(model: string): boolean {\n  return GEMINI_3_FLASH_REGEX.test(model);\n}\n\n/**\n * Resolves a model name with optional tier suffix and quota prefix to its actual API model name\n * and corresponding thinking configuration.\n *\n * Quota routing:\n * - Default to Antigravity quota unless cli_first is enabled for Gemini models\n * - Fallback to Gemini CLI happens at account rotation level when Antigravity is exhausted\n * - \"antigravity-\" prefix marks explicit quota (no fallback allowed)\n * - Claude and image models always use Antigravity\n *\n * Examples:\n * - \"gemini-2.5-flash\" → { quotaPreference: \"antigravity\" }\n * - \"gemini-3-pro-preview\" → { quotaPreference: \"antigravity\" }\n * - \"antigravity-gemini-3-pro-high\" → { quotaPreference: \"antigravity\", explicitQuota: true }\n * - \"claude-opus-4-6-thinking-medium\" → { quotaPreference: \"antigravity\" }\n *\n * @param requestedModel - The model name from the request\n * @param options - Optional configuration including cli_first preference\n * @returns Resolved model with thinking configuration\n */\nexport function resolveModelWithTier(requestedModel: string, options: ModelResolverOptions = {}): ResolvedModel {\n  const isAntigravity = QUOTA_PREFIX_REGEX.test(requestedModel);\n  const modelWithoutQuota = requestedModel.replace(QUOTA_PREFIX_REGEX, \"\");\n\n  const tier = extractThinkingTierFromModel(modelWithoutQuota);\n  const baseName = tier ? modelWithoutQuota.replace(TIER_REGEX, \"\") : modelWithoutQuota;\n\n  const isImageModel = IMAGE_GENERATION_MODELS.test(modelWithoutQuota);\n  const isClaudeModel = modelWithoutQuota.toLowerCase().includes(\"claude\");\n  \n  // All models default to Antigravity quota unless cli_first is enabled\n  // Fallback to gemini-cli happens at the account rotation level when Antigravity is exhausted\n  const preferGeminiCli = options.cli_first === true && !isAntigravity && !isImageModel && !isClaudeModel;\n  const quotaPreference = preferGeminiCli ? \"gemini-cli\" as const : \"antigravity\" as const;\n  const explicitQuota = isAntigravity || isImageModel;\n\n  const isGemini3 = modelWithoutQuota.toLowerCase().startsWith(\"gemini-3\");\n  const skipAlias = isAntigravity && isGemini3;\n\n  // For Antigravity Gemini 3 Pro models without explicit tier, append default tier\n  // Antigravity API: gemini-3-pro requires tier suffix (gemini-3-pro-low/high)\n  //                  gemini-3-flash uses bare name + thinkingLevel param\n  // Pro defaults to -low unless an explicit tier is provided\n  const isGemini3Pro = isGemini3ProModel(modelWithoutQuota);\n  const isGemini3Flash = isGemini3FlashModel(modelWithoutQuota);\n  \n  let antigravityModel = modelWithoutQuota;\n  if (skipAlias) {\n    if (isGemini3Pro && !tier && !isImageModel) {\n      antigravityModel = `${modelWithoutQuota}-low`;\n    } else if (isGemini3Flash && tier) {\n      antigravityModel = baseName;\n    }\n  }\n\n  const actualModel = skipAlias\n    ? antigravityModel\n    : MODEL_ALIASES[modelWithoutQuota] || MODEL_ALIASES[baseName] || baseName;\n\n  const resolvedModel = actualModel;\n\n  const isThinking = isThinkingCapableModel(resolvedModel);\n\n  // Image generation models don't support thinking - return early without thinking config\n  if (isImageModel) {\n    return {\n      actualModel: resolvedModel,\n      isThinkingModel: false,\n      isImageModel: true,\n      quotaPreference,\n      explicitQuota,\n    };\n  }\n\n  // Check if this is a Gemini 3 model (works for both aliased and skipAlias paths)\n  const isEffectiveGemini3 = resolvedModel.toLowerCase().includes(\"gemini-3\");\n  const isClaudeThinking = resolvedModel.toLowerCase().includes(\"claude\") && resolvedModel.toLowerCase().includes(\"thinking\");\n\n  if (!tier) {\n    // Gemini 3 models without explicit tier get a default thinkingLevel\n    if (isEffectiveGemini3) {\n      return {\n        actualModel: resolvedModel,\n        thinkingLevel: \"low\",\n        isThinkingModel: true,\n        quotaPreference,\n        explicitQuota,\n      };\n    }\n    // Claude thinking models without explicit tier get max budget (32768)\n    // Per Anthropic docs, budget_tokens is required when enabling extended thinking\n    if (isClaudeThinking) {\n      return {\n        actualModel: resolvedModel,\n        thinkingBudget: THINKING_TIER_BUDGETS.claude.high,\n        isThinkingModel: true,\n        quotaPreference,\n        explicitQuota,\n      };\n    }\n    return { actualModel: resolvedModel, isThinkingModel: isThinking, quotaPreference, explicitQuota };\n  }\n\n  // Gemini 3 models with tier always get thinkingLevel set\n  if (isEffectiveGemini3) {\n    return {\n      actualModel: resolvedModel,\n      thinkingLevel: tier,\n      tier,\n      isThinkingModel: true,\n      quotaPreference,\n      explicitQuota,\n    };\n  }\n\n  const budgetFamily = getBudgetFamily(resolvedModel);\n  const budgets = THINKING_TIER_BUDGETS[budgetFamily];\n  const thinkingBudget = budgets[tier];\n\n  return {\n    actualModel: resolvedModel,\n    thinkingBudget,\n    tier,\n    isThinkingModel: isThinking,\n    quotaPreference,\n    explicitQuota,\n  };\n}\n\n/**\n * Gets the model family for routing decisions.\n */\nexport function getModelFamily(model: string): \"claude\" | \"gemini-flash\" | \"gemini-pro\" {\n  const lower = model.toLowerCase();\n  if (lower.includes(\"claude\")) {\n    return \"claude\";\n  }\n  if (lower.includes(\"flash\")) {\n    return \"gemini-flash\";\n  }\n  return \"gemini-pro\";\n}\n\n/**\n * Variant config from OpenCode's providerOptions.\n */\nexport interface VariantConfig {\n  thinkingBudget?: number;\n  googleSearch?: GoogleSearchConfig;\n}\n\n/**\n * Maps a thinking budget to Gemini 3 thinking level.\n * ≤8192 → low, ≤16384 → medium, >16384 → high\n */\nfunction budgetToGemini3Level(budget: number): \"low\" | \"medium\" | \"high\" {\n  if (budget <= 8192) return \"low\";\n  if (budget <= 16384) return \"medium\";\n  return \"high\";\n}\n\n/**\n * Resolves model name for a specific headerStyle (quota fallback support).\n * Transforms model names when switching between gemini-cli and antigravity quotas.\n * \n * Issue #103: When quota fallback occurs, model names need to be transformed:\n * - gemini-3-flash-preview (gemini-cli) → gemini-3-flash (antigravity)\n * - gemini-3-pro-preview (gemini-cli) → gemini-3-pro-low (antigravity)\n * - gemini-3-flash (antigravity) → gemini-3-flash-preview (gemini-cli)\n */\nexport function resolveModelForHeaderStyle(\n  requestedModel: string,\n  headerStyle: \"antigravity\" | \"gemini-cli\"\n): ResolvedModel {\n  const lower = requestedModel.toLowerCase();\n  const isGemini3 = lower.includes(\"gemini-3\");\n  \n  if (!isGemini3) {\n    return resolveModelWithTier(requestedModel);\n  }\n\n  if (headerStyle === \"antigravity\") {\n    let transformedModel = requestedModel\n      .replace(/-preview-customtools$/i, \"\")\n      .replace(/-preview$/i, \"\")\n      .replace(/^antigravity-/i, \"\");\n    \n    const isGemini3Pro = isGemini3ProModel(transformedModel);\n    const hasTierSuffix = /-(low|medium|high)$/i.test(transformedModel);\n    const isImageModel = IMAGE_GENERATION_MODELS.test(transformedModel);\n    \n    // Don't add tier suffix to image models - they don't support thinking\n    if (isGemini3Pro && !hasTierSuffix && !isImageModel) {\n      transformedModel = `${transformedModel}-low`;\n    }\n    \n    const prefixedModel = `antigravity-${transformedModel}`;\n    return resolveModelWithTier(prefixedModel);\n  }\n  \n  if (headerStyle === \"gemini-cli\") {\n    let transformedModel = requestedModel\n      .replace(/^antigravity-/i, \"\")\n      .replace(/-(low|medium|high)$/i, \"\");\n\n    const hasPreviewSuffix = /-preview($|-)/i.test(transformedModel);\n    if (!hasPreviewSuffix) {\n      transformedModel = `${transformedModel}-preview`;\n    }\n    \n    return {\n      ...resolveModelWithTier(transformedModel),\n      quotaPreference: \"gemini-cli\",\n    };\n  }\n\n  return resolveModelWithTier(requestedModel);\n}\n\n/**\n * Resolves model with variant config from providerOptions.\n * Variant config takes priority over tier suffix in model name.\n */\nexport function resolveModelWithVariant(\n  requestedModel: string,\n  variantConfig?: VariantConfig\n): ResolvedModel {\n  const base = resolveModelWithTier(requestedModel);\n\n  if (!variantConfig) {\n    return base;\n  }\n\n  // Apply Google Search config if present\n  if (variantConfig.googleSearch) {\n    base.googleSearch = variantConfig.googleSearch;\n    base.configSource = \"variant\";\n  }\n\n  if (!variantConfig.thinkingBudget) {\n    return base;\n  }\n\n  const budget = variantConfig.thinkingBudget;\n  const isGemini3 = base.actualModel.toLowerCase().includes(\"gemini-3\");\n\n  if (isGemini3) {\n    const level = budgetToGemini3Level(budget);\n    const isAntigravityGemini3Pro = base.quotaPreference === \"antigravity\" &&\n      isGemini3ProModel(base.actualModel);\n\n    let actualModel = base.actualModel;\n    if (isAntigravityGemini3Pro) {\n      const baseModel = base.actualModel.replace(/-(low|medium|high)$/, \"\");\n      actualModel = `${baseModel}-${level}`;\n    }\n\n    return {\n      ...base,\n      actualModel,\n      thinkingLevel: level,\n      thinkingBudget: undefined,\n      configSource: \"variant\",\n    };\n  }\n\n  return {\n    ...base,\n    thinkingBudget: budget,\n    configSource: \"variant\",\n  };\n}\n"
  },
  {
    "path": "src/plugin/transform/types.ts",
    "content": "import type { HeaderStyle } from \"../../constants\";\n\nexport type ModelFamily = \"claude\" | \"gemini-flash\" | \"gemini-pro\";\n\nexport type ThinkingTier = \"low\" | \"medium\" | \"high\";\n\n/**\n * Context for request transformation.\n * Contains all information needed to transform a request payload.\n */\nexport interface TransformContext {\n  /** The resolved project ID for the API call */\n  projectId: string;\n  /** The resolved model name (after alias resolution) */\n  model: string;\n  /** The original model name from the request */\n  requestedModel: string;\n  /** Model family for routing decisions */\n  family: ModelFamily;\n  /** Whether this is a streaming request */\n  streaming: boolean;\n  /** Unique request ID for tracking */\n  requestId: string;\n  /** Session ID for signature caching */\n  sessionId?: string;\n  /** Thinking tier if specified via model suffix */\n  thinkingTier?: ThinkingTier;\n  /** Thinking budget for Claude models (derived from tier) */\n  thinkingBudget?: number;\n  /** Thinking level for Gemini 3 models (derived from tier) */\n  thinkingLevel?: string;\n}\n\n/**\n * Result of request transformation.\n */\nexport interface TransformResult {\n  /** The transformed request body as JSON string */\n  body: string;\n  /** Debug information about the transformation */\n  debugInfo: TransformDebugInfo;\n}\n\n/**\n * Debug information from transformation.\n */\nexport interface TransformDebugInfo {\n  /** Which transformer was used */\n  transformer: \"claude\" | \"gemini\";\n  /** Number of tools in the request */\n  toolCount: number;\n  /** Whether tools were transformed */\n  toolsTransformed?: boolean;\n  /** Thinking tier if resolved */\n  thinkingTier?: string;\n  /** Thinking budget if set */\n  thinkingBudget?: number;\n  /** Thinking level if set (Gemini 3) */\n  thinkingLevel?: string;\n}\n\n/**\n * Generic request payload type.\n * The actual structure varies between Claude and Gemini.\n */\nexport type RequestPayload = Record<string, unknown>;\n\n/**\n * Thinking configuration normalized from various input formats.\n */\nexport interface ThinkingConfig {\n  /** Numeric thinking budget (for Claude and Gemini 2.5) */\n  thinkingBudget?: number;\n  /** String thinking level (for Gemini 3: 'low', 'medium', 'high') */\n  thinkingLevel?: string;\n  /** Whether to include thinking in the response */\n  includeThoughts?: boolean;\n  /** Snake_case variant for Antigravity backend */\n  include_thoughts?: boolean;\n}\n\n/**\n * Google Search Grounding configuration.\n *\n * Note: The new googleSearch API for Gemini 2.0+ does not support threshold\n * configuration. The model automatically decides when to search.\n * The threshold field is kept for backward compatibility but is ignored.\n */\nexport interface GoogleSearchConfig {\n  mode?: 'auto' | 'off';\n  /** @deprecated No longer used - kept for backward compatibility */\n  threshold?: number;\n}\n\n/**\n * Model resolution result with tier information.\n */\nexport interface ResolvedModel {\n  /** The actual model name for the API call */\n  actualModel: string;\n  /** Thinking level for Gemini 3 models */\n  thinkingLevel?: string;\n  /** Thinking budget for Claude/Gemini 2.5 */\n  thinkingBudget?: number;\n  /** The tier suffix that was extracted */\n  tier?: ThinkingTier;\n  /** Whether this is a thinking-capable model */\n  isThinkingModel?: boolean;\n  /** Whether this is an image generation model */\n  isImageModel?: boolean;\n  /** Quota preference - all models default to antigravity, with CLI as fallback */\n  quotaPreference?: HeaderStyle;\n  /** Whether user explicitly specified quota via suffix (vs default selection) */\n  explicitQuota?: boolean;\n  /** Source of thinking config: \"variant\" (providerOptions) or \"tier\" (model suffix) */\n  configSource?: \"variant\" | \"tier\";\n  /** Google Search configuration from variant or global config */\n  googleSearch?: GoogleSearchConfig;\n}\n"
  },
  {
    "path": "src/plugin/types.ts",
    "content": "import type { PluginInput } from \"@opencode-ai/plugin\";\nimport type { AntigravityTokenExchangeResult } from \"../antigravity/oauth\";\n\nexport interface OAuthAuthDetails {\n  type: \"oauth\";\n  refresh: string;\n  access?: string;\n  expires?: number;\n}\n\nexport interface ApiKeyAuthDetails {\n  type: \"api_key\";\n  key: string;\n}\n\nexport interface NonOAuthAuthDetails {\n  type: string;\n  [key: string]: unknown;\n}\n\nexport type AuthDetails = OAuthAuthDetails | ApiKeyAuthDetails | NonOAuthAuthDetails;\n\nexport type GetAuth = () => Promise<AuthDetails>;\n\nexport interface ProviderModel {\n  cost?: {\n    input: number;\n    output: number;\n  };\n  [key: string]: unknown;\n}\n\nexport interface Provider {\n  models?: Record<string, ProviderModel>;\n}\n\nexport interface LoaderResult {\n  apiKey: string;\n  fetch(input: RequestInfo, init?: RequestInit): Promise<Response>;\n}\n\nexport type PluginClient = PluginInput[\"client\"];\n\nexport interface PluginContext {\n  client: PluginClient;\n  directory: string;\n}\n\nexport type AuthPrompt =\n  | {\n      type: \"text\";\n      key: string;\n      message: string;\n      placeholder?: string;\n      validate?: (value: string) => string | undefined;\n      condition?: (inputs: Record<string, string>) => boolean;\n    }\n  | {\n      type: \"select\";\n      key: string;\n      message: string;\n      options: Array<{ label: string; value: string; hint?: string }>;\n      condition?: (inputs: Record<string, string>) => boolean;\n    };\n\nexport type OAuthAuthorizationResult = { url: string; instructions: string } & (\n  | {\n      method: \"auto\";\n      callback: () => Promise<AntigravityTokenExchangeResult>;\n    }\n  | {\n      method: \"code\";\n      callback: (code: string) => Promise<AntigravityTokenExchangeResult>;\n    }\n);\n\nexport interface AuthMethod {\n  provider?: string;\n  label: string;\n  type: \"oauth\" | \"api\";\n  prompts?: AuthPrompt[];\n  authorize?: (inputs?: Record<string, string>) => Promise<OAuthAuthorizationResult>;\n}\n\nexport interface PluginEventPayload {\n  event: {\n    type: string;\n    properties?: unknown;\n  };\n}\n\nexport interface PluginResult {\n  auth: {\n    provider: string;\n    loader: (getAuth: GetAuth, provider: Provider) => Promise<LoaderResult | Record<string, unknown>>;\n    methods: AuthMethod[];\n  };\n  event?: (payload: PluginEventPayload) => void;\n  tool?: Record<string, unknown>;\n}\n\nexport interface RefreshParts {\n  refreshToken: string;\n  projectId?: string;\n  managedProjectId?: string;\n}\n\nexport interface ProjectContextResult {\n  auth: OAuthAuthDetails;\n  effectiveProjectId: string;\n}\n\n"
  },
  {
    "path": "src/plugin/ui/ansi.test.ts",
    "content": "import { describe, it, expect } from 'vitest';\nimport { parseKey, isTTY, ANSI } from './ansi';\n\ndescribe('ansi', () => {\n  describe('parseKey', () => {\n    it('parses arrow up sequences', () => {\n      expect(parseKey(Buffer.from('\\x1b[A'))).toBe('up');\n      expect(parseKey(Buffer.from('\\x1bOA'))).toBe('up');\n    });\n\n    it('parses arrow down sequences', () => {\n      expect(parseKey(Buffer.from('\\x1b[B'))).toBe('down');\n      expect(parseKey(Buffer.from('\\x1bOB'))).toBe('down');\n    });\n\n    it('parses enter key (CR and LF)', () => {\n      expect(parseKey(Buffer.from('\\r'))).toBe('enter');\n      expect(parseKey(Buffer.from('\\n'))).toBe('enter');\n    });\n\n    it('parses Ctrl+C as escape', () => {\n      expect(parseKey(Buffer.from('\\x03'))).toBe('escape');\n    });\n\n    it('parses bare escape as escape-start', () => {\n      expect(parseKey(Buffer.from('\\x1b'))).toBe('escape-start');\n    });\n\n    it('returns null for unknown keys', () => {\n      expect(parseKey(Buffer.from('a'))).toBe(null);\n      expect(parseKey(Buffer.from('1'))).toBe(null);\n      expect(parseKey(Buffer.from(' '))).toBe(null);\n      expect(parseKey(Buffer.from('\\t'))).toBe(null);\n    });\n\n    it('returns null for partial escape sequences', () => {\n      expect(parseKey(Buffer.from('\\x1b['))).toBe(null);\n      expect(parseKey(Buffer.from('\\x1bO'))).toBe(null);\n    });\n\n    it('returns null for other arrow keys', () => {\n      expect(parseKey(Buffer.from('\\x1b[C'))).toBe(null);\n      expect(parseKey(Buffer.from('\\x1b[D'))).toBe(null);\n    });\n  });\n\n  describe('ANSI codes', () => {\n    it('has cursor control codes', () => {\n      expect(ANSI.hide).toBe('\\x1b[?25l');\n      expect(ANSI.show).toBe('\\x1b[?25h');\n      expect(ANSI.clearLine).toBe('\\x1b[2K');\n    });\n\n    it('generates cursor movement codes', () => {\n      expect(ANSI.up(1)).toBe('\\x1b[1A');\n      expect(ANSI.up(5)).toBe('\\x1b[5A');\n      expect(ANSI.down(1)).toBe('\\x1b[1B');\n      expect(ANSI.down(3)).toBe('\\x1b[3B');\n    });\n\n    it('has color codes', () => {\n      expect(ANSI.cyan).toBe('\\x1b[36m');\n      expect(ANSI.green).toBe('\\x1b[32m');\n      expect(ANSI.red).toBe('\\x1b[31m');\n      expect(ANSI.yellow).toBe('\\x1b[33m');\n      expect(ANSI.reset).toBe('\\x1b[0m');\n    });\n\n    it('has style codes', () => {\n      expect(ANSI.dim).toBe('\\x1b[2m');\n      expect(ANSI.bold).toBe('\\x1b[1m');\n    });\n  });\n\n  describe('isTTY', () => {\n    it('returns boolean', () => {\n      expect(typeof isTTY()).toBe('boolean');\n    });\n  });\n});\n"
  },
  {
    "path": "src/plugin/ui/ansi.ts",
    "content": "/**\n * ANSI escape codes and key parsing for interactive CLI menus.\n * Works cross-platform (Windows/Mac/Linux).\n */\n\nexport const ANSI = {\n  // Cursor control\n  hide: '\\x1b[?25l',\n  show: '\\x1b[?25h',\n  up: (n = 1) => `\\x1b[${n}A`,\n  down: (n = 1) => `\\x1b[${n}B`,\n  clearLine: '\\x1b[2K',\n  clearScreen: '\\x1b[2J',\n  moveTo: (row: number, col: number) => `\\x1b[${row};${col}H`,\n  \n  // Styles\n  cyan: '\\x1b[36m',\n  green: '\\x1b[32m',\n  red: '\\x1b[31m',\n  yellow: '\\x1b[33m',\n  dim: '\\x1b[2m',\n  bold: '\\x1b[1m',\n  reset: '\\x1b[0m',\n  inverse: '\\x1b[7m',\n} as const;\n\nexport type KeyAction = 'up' | 'down' | 'enter' | 'escape' | 'escape-start' | null;\n\n/**\n * Parse raw keyboard input buffer into a key action.\n * Handles Windows/Mac/Linux differences in arrow key sequences.\n */\nexport function parseKey(data: Buffer): KeyAction {\n  const s = data.toString();\n  \n  // Arrow keys (ANSI escape sequences)\n  // Standard: \\x1b[A (up), \\x1b[B (down)\n  // Application mode: \\x1bOA (up), \\x1bOB (down)\n  if (s === '\\x1b[A' || s === '\\x1bOA') return 'up';\n  if (s === '\\x1b[B' || s === '\\x1bOB') return 'down';\n  \n  // Enter (CR or LF)\n  if (s === '\\r' || s === '\\n') return 'enter';\n  \n  if (s === '\\x03') return 'escape';\n  \n  if (s === '\\x1b') return 'escape-start';\n  \n  return null;\n}\n\n/**\n * Check if the terminal supports interactive input.\n */\nexport function isTTY(): boolean {\n  return Boolean(process.stdin.isTTY);\n}\n"
  },
  {
    "path": "src/plugin/ui/auth-menu.test.ts",
    "content": "import { describe, it, expect } from 'vitest';\nimport { ANSI } from './ansi';\n\nfunction formatRelativeTime(timestamp: number | undefined): string {\n  if (!timestamp) return 'never';\n  const days = Math.floor((Date.now() - timestamp) / 86400000);\n  if (days === 0) return 'today';\n  if (days === 1) return 'yesterday';\n  if (days < 7) return `${days}d ago`;\n  if (days < 30) return `${Math.floor(days / 7)}w ago`;\n  return new Date(timestamp).toLocaleDateString();\n}\n\nfunction formatDate(timestamp: number | undefined): string {\n  if (!timestamp) return 'unknown';\n  return new Date(timestamp).toLocaleDateString();\n}\n\ntype AccountStatus = 'active' | 'rate-limited' | 'expired' | 'unknown';\n\nfunction getStatusBadge(status: AccountStatus | undefined): string {\n  switch (status) {\n    case 'active': return `${ANSI.green}[active]${ANSI.reset}`;\n    case 'rate-limited': return `${ANSI.yellow}[rate-limited]${ANSI.reset}`;\n    case 'expired': return `${ANSI.red}[expired]${ANSI.reset}`;\n    default: return '';\n  }\n}\n\ndescribe('auth-menu helpers', () => {\n  describe('formatRelativeTime', () => {\n    it('returns \"never\" for undefined', () => {\n      expect(formatRelativeTime(undefined)).toBe('never');\n    });\n\n    it('returns \"today\" for same day', () => {\n      expect(formatRelativeTime(Date.now())).toBe('today');\n      expect(formatRelativeTime(Date.now() - 1000)).toBe('today');\n    });\n\n    it('returns \"yesterday\" for 1 day ago', () => {\n      const yesterday = Date.now() - 86400000;\n      expect(formatRelativeTime(yesterday)).toBe('yesterday');\n    });\n\n    it('returns \"Xd ago\" for 2-6 days', () => {\n      expect(formatRelativeTime(Date.now() - 2 * 86400000)).toBe('2d ago');\n      expect(formatRelativeTime(Date.now() - 6 * 86400000)).toBe('6d ago');\n    });\n\n    it('returns \"Xw ago\" for 7-29 days', () => {\n      expect(formatRelativeTime(Date.now() - 7 * 86400000)).toBe('1w ago');\n      expect(formatRelativeTime(Date.now() - 14 * 86400000)).toBe('2w ago');\n      expect(formatRelativeTime(Date.now() - 28 * 86400000)).toBe('4w ago');\n    });\n\n    it('returns formatted date for 30+ days', () => {\n      const oldDate = Date.now() - 60 * 86400000;\n      const result = formatRelativeTime(oldDate);\n      expect(result).not.toBe('never');\n      expect(result).not.toContain('ago');\n    });\n  });\n\n  describe('formatDate', () => {\n    it('returns \"unknown\" for undefined', () => {\n      expect(formatDate(undefined)).toBe('unknown');\n    });\n\n    it('returns formatted date for valid timestamp', () => {\n      const result = formatDate(Date.now());\n      expect(result).not.toBe('unknown');\n      expect(typeof result).toBe('string');\n    });\n  });\n\n  describe('getStatusBadge', () => {\n    it('returns green badge for active status', () => {\n      const badge = getStatusBadge('active');\n      expect(badge).toContain('[active]');\n      expect(badge).toContain(ANSI.green);\n    });\n\n    it('returns yellow badge for rate-limited status', () => {\n      const badge = getStatusBadge('rate-limited');\n      expect(badge).toContain('[rate-limited]');\n      expect(badge).toContain(ANSI.yellow);\n    });\n\n    it('returns red badge for expired status', () => {\n      const badge = getStatusBadge('expired');\n      expect(badge).toContain('[expired]');\n      expect(badge).toContain(ANSI.red);\n    });\n\n    it('returns empty string for unknown status', () => {\n      expect(getStatusBadge('unknown')).toBe('');\n      expect(getStatusBadge(undefined)).toBe('');\n    });\n  });\n});\n"
  },
  {
    "path": "src/plugin/ui/auth-menu.ts",
    "content": "import { ANSI } from './ansi';\nimport { select, type MenuItem } from './select';\nimport { confirm } from './confirm';\n\nexport type AccountStatus = 'active' | 'rate-limited' | 'expired' | 'verification-required' | 'unknown';\n\nexport interface AccountInfo {\n  email?: string;\n  index: number;\n  addedAt?: number;\n  lastUsed?: number;\n  status?: AccountStatus;\n  isCurrentAccount?: boolean;\n  enabled?: boolean;\n}\n\nexport type AuthMenuAction =\n  | { type: 'add' }\n  | { type: 'select-account'; account: AccountInfo }\n  | { type: 'delete-all' }\n  | { type: 'check' }\n  | { type: 'verify' }\n  | { type: 'verify-all' }\n  | { type: 'configure-models' }\n  | { type: 'cancel' };\n\nexport type AccountAction = 'back' | 'delete' | 'refresh' | 'toggle' | 'verify' | 'cancel';\n\nfunction formatRelativeTime(timestamp: number | undefined): string {\n  if (!timestamp) return 'never';\n  const days = Math.floor((Date.now() - timestamp) / 86400000);\n  if (days === 0) return 'today';\n  if (days === 1) return 'yesterday';\n  if (days < 7) return `${days}d ago`;\n  if (days < 30) return `${Math.floor(days / 7)}w ago`;\n  return new Date(timestamp).toLocaleDateString();\n}\n\nfunction formatDate(timestamp: number | undefined): string {\n  if (!timestamp) return 'unknown';\n  return new Date(timestamp).toLocaleDateString();\n}\n\nfunction getStatusBadge(status: AccountStatus | undefined): string {\n  switch (status) {\n    case 'active': return `${ANSI.green}[active]${ANSI.reset}`;\n    case 'rate-limited': return `${ANSI.yellow}[rate-limited]${ANSI.reset}`;\n    case 'expired': return `${ANSI.red}[expired]${ANSI.reset}`;\n    case 'verification-required': return `${ANSI.red}[needs verification]${ANSI.reset}`;\n    default: return '';\n  }\n}\n\nexport async function showAuthMenu(accounts: AccountInfo[]): Promise<AuthMenuAction> {\n  const items: MenuItem<AuthMenuAction>[] = [\n    { label: 'Actions', value: { type: 'cancel' }, kind: 'heading' },\n    { label: 'Add account', value: { type: 'add' }, color: 'cyan' },\n    { label: 'Check quotas', value: { type: 'check' }, color: 'cyan' },\n    { label: 'Verify one account', value: { type: 'verify' }, color: 'cyan' },\n    { label: 'Verify all accounts', value: { type: 'verify-all' }, color: 'cyan' },\n    { label: 'Configure models in opencode.json', value: { type: 'configure-models' }, color: 'cyan' },\n\n    { label: '', value: { type: 'cancel' }, separator: true },\n\n    { label: 'Accounts', value: { type: 'cancel' }, kind: 'heading' },\n\n    ...accounts.map(account => {\n      const statusBadge = getStatusBadge(account.status);\n      const currentBadge = account.isCurrentAccount ? ` ${ANSI.cyan}[current]${ANSI.reset}` : '';\n      const disabledBadge = account.enabled === false ? ` ${ANSI.red}[disabled]${ANSI.reset}` : '';\n      const baseLabel = account.email || `Account ${account.index + 1}`;\n      const numbered = `${account.index + 1}. ${baseLabel}`;\n      const fullLabel = `${numbered}${currentBadge}${statusBadge ? ' ' + statusBadge : ''}${disabledBadge}`;\n\n      return {\n        label: fullLabel,\n        hint: account.lastUsed ? `used ${formatRelativeTime(account.lastUsed)}` : '',\n        value: { type: 'select-account' as const, account },\n      };\n    }),\n\n    { label: '', value: { type: 'cancel' }, separator: true },\n\n    { label: 'Danger zone', value: { type: 'cancel' }, kind: 'heading' },\n    { label: 'Delete all accounts', value: { type: 'delete-all' }, color: 'red' as const },\n  ];\n\n  while (true) {\n    const result = await select(items, { \n      message: 'Google accounts (Antigravity)',\n      subtitle: 'Select an action or account',\n      clearScreen: true,\n    });\n\n    if (!result) return { type: 'cancel' };\n\n    if (result.type === 'delete-all') {\n      const confirmed = await confirm('Delete ALL accounts? This cannot be undone.');\n      if (!confirmed) continue;\n    }\n\n    return result;\n  }\n}\n\nexport async function showAccountDetails(account: AccountInfo): Promise<AccountAction> {\n  const label = account.email || `Account ${account.index + 1}`;\n  const badge = getStatusBadge(account.status);\n  const disabledBadge = account.enabled === false ? ` ${ANSI.red}[disabled]${ANSI.reset}` : '';\n  const header = `${label}${badge ? ' ' + badge : ''}${disabledBadge}`;\n  const subtitleParts = [\n    `Added: ${formatDate(account.addedAt)}`,\n    `Last used: ${formatRelativeTime(account.lastUsed)}`,\n  ];\n\n  while (true) {\n    const result = await select([\n      { label: 'Back', value: 'back' as const },\n      { label: 'Verify account access', value: 'verify' as const, color: 'cyan' },\n      { label: account.enabled === false ? 'Enable account' : 'Disable account', value: 'toggle' as const, color: account.enabled === false ? 'green' : 'yellow' },\n      { label: 'Refresh token', value: 'refresh' as const, color: 'cyan' },\n      { label: 'Delete this account', value: 'delete' as const, color: 'red' },\n    ], { \n      message: header,\n      subtitle: subtitleParts.join(' | '),\n      clearScreen: true,\n    });\n\n    if (result === 'delete') {\n      const confirmed = await confirm(`Delete ${label}?`);\n      if (!confirmed) continue;\n    }\n\n    if (result === 'refresh') {\n      const confirmed = await confirm(`Re-authenticate ${label}?`);\n      if (!confirmed) continue;\n    }\n\n    return result ?? 'cancel';\n  }\n}\n\nexport { isTTY } from './ansi';\n"
  },
  {
    "path": "src/plugin/ui/confirm.ts",
    "content": "import { select } from './select';\n\nexport async function confirm(message: string, defaultYes = false): Promise<boolean> {\n  const items = defaultYes\n    ? [\n        { label: 'Yes', value: true },\n        { label: 'No', value: false },\n      ]\n    : [\n        { label: 'No', value: false },\n        { label: 'Yes', value: true },\n      ];\n\n  const result = await select(items, { message });\n  return result ?? false;\n}\n"
  },
  {
    "path": "src/plugin/ui/select.ts",
    "content": "import { ANSI, isTTY, parseKey } from './ansi';\n\nexport interface MenuItem<T = string> {\n  label: string;\n  value: T;\n  hint?: string;\n  disabled?: boolean;\n  separator?: boolean;\n  /** Non-selectable label row (section heading). */\n  kind?: 'heading';\n  color?: 'red' | 'green' | 'yellow' | 'cyan';\n}\n\nexport interface SelectOptions {\n  message: string;\n  subtitle?: string;\n  /** Override the help line shown at the bottom of the menu. */\n  help?: string;\n  /**\n   * Clear the terminal before each render (opt-in).\n   * Useful for nested flows where previous logs make menus feel cluttered.\n   */\n  clearScreen?: boolean;\n}\n\nconst ESCAPE_TIMEOUT_MS = 50;\n\nconst ANSI_REGEX = new RegExp(\"\\\\x1b\\\\[[0-9;]*m\", \"g\");\nconst ANSI_LEADING_REGEX = new RegExp(\"^\\\\x1b\\\\[[0-9;]*m\");\n\nfunction stripAnsi(input: string): string {\n  return input.replace(ANSI_REGEX, '');\n}\n\nfunction truncateAnsi(input: string, maxVisibleChars: number): string {\n  if (maxVisibleChars <= 0) return '';\n\n  const visible = stripAnsi(input);\n  if (visible.length <= maxVisibleChars) return input;\n\n  const suffix = maxVisibleChars >= 3 ? '...' : '.'.repeat(maxVisibleChars);\n  const keep = Math.max(0, maxVisibleChars - suffix.length);\n\n  let out = '';\n  let i = 0;\n  let kept = 0;\n\n  while (i < input.length && kept < keep) {\n    // Preserve ANSI sequences without counting them.\n    if (input[i] === '\\x1b') {\n      const m = input.slice(i).match(ANSI_LEADING_REGEX);\n      if (m) {\n        out += m[0];\n        i += m[0].length;\n        continue;\n      }\n    }\n\n    out += input[i];\n    i += 1;\n    kept += 1;\n  }\n\n  if (out.includes('\\x1b[')) {\n    return `${out}${ANSI.reset}${suffix}`;\n  }\n\n  return out + suffix;\n}\n\nfunction getColorCode(color: MenuItem['color']): string {\n  switch (color) {\n    case 'red': return ANSI.red;\n    case 'green': return ANSI.green;\n    case 'yellow': return ANSI.yellow;\n    case 'cyan': return ANSI.cyan;\n    default: return '';\n  }\n}\n\nexport async function select<T>(\n  items: MenuItem<T>[],\n  options: SelectOptions\n): Promise<T | null> {\n  if (!isTTY()) {\n    throw new Error('Interactive select requires a TTY terminal');\n  }\n\n  if (items.length === 0) {\n    throw new Error('No menu items provided');\n  }\n\n  const isSelectable = (i: MenuItem<T>) => !i.disabled && !i.separator && i.kind !== 'heading';\n  const enabledItems = items.filter(isSelectable);\n  if (enabledItems.length === 0) {\n    throw new Error('All items disabled');\n  }\n\n  if (enabledItems.length === 1) {\n    return enabledItems[0]!.value;\n  }\n\n  const { message, subtitle } = options;\n  const { stdin, stdout } = process;\n\n  let cursor = items.findIndex(isSelectable);\n  if (cursor === -1) cursor = 0; // Fallback, though validation above should prevent this\n  let escapeTimeout: ReturnType<typeof setTimeout> | null = null;\n  let isCleanedUp = false;\n  let renderedLines = 0;\n\n  const render = () => {\n    const columns = stdout.columns ?? 80;\n    const rows = stdout.rows ?? 24;\n    const shouldClearScreen = options.clearScreen === true;\n    const previousRenderedLines = renderedLines;\n\n    if (shouldClearScreen) {\n      stdout.write(ANSI.clearScreen + ANSI.moveTo(1, 1));\n    } else if (previousRenderedLines > 0) {\n      stdout.write(ANSI.up(previousRenderedLines));\n    }\n\n    let linesWritten = 0;\n    const writeLine = (line: string) => {\n      stdout.write(`${ANSI.clearLine}${line}\\n`);\n      linesWritten += 1;\n    };\n\n    // Subtitle renders as 3 lines:\n    // 1) blank \"│\" spacer, 2) subtitle line, 3) blank line. Header is counted separately.\n    const subtitleLines = subtitle ? 3 : 0;\n    const fixedLines = 1 + subtitleLines + 2; // header + subtitle + (help + bottom)\n    // Keep a small safety margin so the final newline doesn't scroll the terminal.\n    const maxVisibleItems = Math.max(1, Math.min(items.length, rows - fixedLines - 1));\n\n    // If the menu is taller than the viewport, only render a window around the cursor.\n    // This prevents terminal scrollback spam (e.g. repeated headers when pressing arrows).\n    let windowStart = 0;\n    let windowEnd = items.length;\n    if (items.length > maxVisibleItems) {\n      windowStart = cursor - Math.floor(maxVisibleItems / 2);\n      windowStart = Math.max(0, Math.min(windowStart, items.length - maxVisibleItems));\n      windowEnd = windowStart + maxVisibleItems;\n    }\n\n    const visibleItems = items.slice(windowStart, windowEnd);\n    const headerMessage = truncateAnsi(message, Math.max(1, columns - 4));\n    writeLine(`${ANSI.dim}┌  ${ANSI.reset}${headerMessage}`);\n    \n    if (subtitle) {\n      writeLine(`${ANSI.dim}│${ANSI.reset}`);\n      const sub = truncateAnsi(subtitle, Math.max(1, columns - 4));\n      writeLine(`${ANSI.cyan}◆${ANSI.reset}  ${sub}`);\n      writeLine(\"\");\n    }\n\n    for (let i = 0; i < visibleItems.length; i++) {\n      const itemIndex = windowStart + i;\n      const item = visibleItems[i];\n      if (!item) continue;\n\n      if (item.separator) {\n        writeLine(`${ANSI.dim}│${ANSI.reset}`);\n        continue;\n      }\n\n      if (item.kind === 'heading') {\n        const heading = truncateAnsi(`${ANSI.dim}${ANSI.bold}${item.label}${ANSI.reset}`, Math.max(1, columns - 6));\n        writeLine(`${ANSI.cyan}│${ANSI.reset}  ${heading}`);\n        continue;\n      }\n\n      const isSelected = itemIndex === cursor;\n      const colorCode = getColorCode(item.color);\n\n      let labelText: string;\n      if (item.disabled) {\n        labelText = `${ANSI.dim}${item.label} (unavailable)${ANSI.reset}`;\n      } else if (isSelected) {\n        labelText = colorCode ? `${colorCode}${item.label}${ANSI.reset}` : item.label;\n        if (item.hint) labelText += ` ${ANSI.dim}${item.hint}${ANSI.reset}`;\n      } else {\n        labelText = colorCode \n          ? `${ANSI.dim}${colorCode}${item.label}${ANSI.reset}` \n          : `${ANSI.dim}${item.label}${ANSI.reset}`;\n        if (item.hint) labelText += ` ${ANSI.dim}${item.hint}${ANSI.reset}`;\n      }\n\n      // Prevent wrapping: cursor positioning relies on a fixed line count.\n      labelText = truncateAnsi(labelText, Math.max(1, columns - 8));\n\n      if (isSelected) {\n        writeLine(`${ANSI.cyan}│${ANSI.reset}  ${ANSI.green}●${ANSI.reset} ${labelText}`);\n      } else {\n        writeLine(`${ANSI.cyan}│${ANSI.reset}  ${ANSI.dim}○${ANSI.reset} ${labelText}`);\n      }\n    }\n\n    const windowHint = items.length > visibleItems.length\n      ? ` (${windowStart + 1}-${windowEnd}/${items.length})`\n      : '';\n    const helpText = options.help ?? `Up/Down to select | Enter: confirm | Esc: back${windowHint}`;\n    const help = truncateAnsi(helpText, Math.max(1, columns - 6));\n    writeLine(`${ANSI.cyan}│${ANSI.reset}  ${ANSI.dim}${help}${ANSI.reset}`);\n    writeLine(`${ANSI.cyan}└${ANSI.reset}`);\n\n    if (!shouldClearScreen && previousRenderedLines > linesWritten) {\n      const extra = previousRenderedLines - linesWritten;\n      for (let i = 0; i < extra; i++) {\n        writeLine(\"\");\n      }\n    }\n\n    renderedLines = linesWritten;\n  };\n\n  return new Promise((resolve) => {\n    const wasRaw = stdin.isRaw ?? false;\n\n    const cleanup = () => {\n      if (isCleanedUp) return;\n      isCleanedUp = true;\n\n      if (escapeTimeout) {\n        clearTimeout(escapeTimeout);\n        escapeTimeout = null;\n      }\n\n      try {\n        stdin.removeListener('data', onKey);\n        stdin.setRawMode(wasRaw);\n        stdin.pause();\n        stdout.write(ANSI.show);\n      } catch {\n        // Intentionally ignored - cleanup is best-effort\n      }\n\n      process.removeListener('SIGINT', onSignal);\n      process.removeListener('SIGTERM', onSignal);\n    };\n\n    const onSignal = () => {\n      cleanup();\n      resolve(null);\n    };\n\n    const finishWithValue = (value: T | null) => {\n      cleanup();\n      resolve(value);\n    };\n\n    const findNextSelectable = (from: number, direction: 1 | -1): number => {\n      if (items.length === 0) return from;\n      \n      let next = from;\n      do {\n        next = (next + direction + items.length) % items.length;\n      } while (items[next]?.disabled || items[next]?.separator || items[next]?.kind === 'heading');\n      return next;\n    };\n\n    const onKey = (data: Buffer) => {\n      if (escapeTimeout) {\n        clearTimeout(escapeTimeout);\n        escapeTimeout = null;\n      }\n\n      const action = parseKey(data);\n\n      switch (action) {\n        case 'up':\n          cursor = findNextSelectable(cursor, -1);\n          render();\n          return;\n        case 'down':\n          cursor = findNextSelectable(cursor, 1);\n          render();\n          return;\n        case 'enter':\n          finishWithValue(items[cursor]?.value ?? null);\n          return;\n        case 'escape':\n          finishWithValue(null);\n          return;\n        case 'escape-start':\n          // Bare escape byte - wait to see if more bytes coming (arrow key sequence)\n          escapeTimeout = setTimeout(() => {\n            finishWithValue(null);\n          }, ESCAPE_TIMEOUT_MS);\n          return;\n        default:\n          // Unknown key - ignore\n          return;\n      }\n    };\n\n    process.once('SIGINT', onSignal);\n    process.once('SIGTERM', onSignal);\n\n    try {\n      stdin.setRawMode(true);\n    } catch {\n      // Failed to enable raw mode - cleanup and return null\n      cleanup();\n      resolve(null);\n      return;\n    }\n\n    stdin.resume();\n    stdout.write(ANSI.hide);\n    render();\n\n    stdin.on('data', onKey);\n  });\n}\n"
  },
  {
    "path": "src/plugin/version.test.ts",
    "content": "import { describe, it, expect, vi, beforeEach, afterEach } from \"vitest\"\n\n/**\n * Regression tests for the version fallback mechanism.\n *\n * Issue #468: On WSL2/AlmaLinux with strict firewall rules, both the\n * auto-updater API and changelog fetch fail. The plugin then uses the\n * hardcoded fallback version in User-Agent headers. If the fallback is\n * too old, the backend rejects requests for newer models (e.g., Gemini 3.1 Pro)\n * with \"not available on this version\".\n *\n * These tests verify the fallback is current and that the\n * network-failure path correctly uses it.\n */\n\n// Reset module state between tests so versionLocked starts fresh\nbeforeEach(() => {\n  vi.resetModules()\n})\n\nafterEach(() => {\n  vi.unstubAllGlobals()\n})\n\ndescribe(\"ANTIGRAVITY_VERSION_FALLBACK\", () => {\n  it(\"defaults to the exported fallback constant\", async () => {\n    const { ANTIGRAVITY_VERSION_FALLBACK, getAntigravityVersion } = await import(\"../constants.ts\")\n    expect(getAntigravityVersion()).toBe(ANTIGRAVITY_VERSION_FALLBACK)\n  })\n\n  it(\"is at least 1.18.0 to support Gemini 3.1 Pro\", async () => {\n    const { getAntigravityVersion } = await import(\"../constants.ts\")\n    const [major, minor] = getAntigravityVersion().split(\".\").map(Number)\n    expect(major).toBeGreaterThanOrEqual(1)\n    if (major === 1) expect(minor).toBeGreaterThanOrEqual(18)\n  })\n})\n\ndescribe(\"setAntigravityVersion\", () => {\n  it(\"updates the version on first call\", async () => {\n    const { getAntigravityVersion, setAntigravityVersion } = await import(\"../constants.ts\")\n    setAntigravityVersion(\"2.0.0\")\n    expect(getAntigravityVersion()).toBe(\"2.0.0\")\n  })\n\n  it(\"locks after first call — subsequent calls are ignored\", async () => {\n    const { getAntigravityVersion, setAntigravityVersion } = await import(\"../constants.ts\")\n    setAntigravityVersion(\"2.0.0\")\n    setAntigravityVersion(\"3.0.0\")\n    expect(getAntigravityVersion()).toBe(\"2.0.0\")\n  })\n})\n\ndescribe(\"initAntigravityVersion — network failure path\", () => {\n  it(\"falls back to hardcoded version when both fetches throw\", async () => {\n    vi.stubGlobal(\"fetch\", vi.fn().mockRejectedValue(new Error(\"network unreachable\")))\n\n    const { ANTIGRAVITY_VERSION_FALLBACK, getAntigravityVersion } = await import(\"../constants.ts\")\n    const { initAntigravityVersion } = await import(\"./version.ts\")\n    await initAntigravityVersion()\n\n    expect(getAntigravityVersion()).toBe(ANTIGRAVITY_VERSION_FALLBACK)\n  })\n\n  it(\"falls back to hardcoded version when both fetches return non-ok\", async () => {\n    vi.stubGlobal(\n      \"fetch\",\n      vi.fn().mockResolvedValue({ ok: false, status: 503, text: async () => \"\" }),\n    )\n\n    const { ANTIGRAVITY_VERSION_FALLBACK, getAntigravityVersion } = await import(\"../constants.ts\")\n    const { initAntigravityVersion } = await import(\"./version.ts\")\n    await initAntigravityVersion()\n\n    expect(getAntigravityVersion()).toBe(ANTIGRAVITY_VERSION_FALLBACK)\n  })\n\n  it(\"uses API version when auto-updater responds\", async () => {\n    vi.stubGlobal(\n      \"fetch\",\n      vi.fn().mockResolvedValue({ ok: true, text: async () => \"1.19.0\" }),\n    )\n\n    const { getAntigravityVersion } = await import(\"../constants.ts\")\n    const { initAntigravityVersion } = await import(\"./version.ts\")\n    await initAntigravityVersion()\n\n    expect(getAntigravityVersion()).toBe(\"1.19.0\")\n  })\n\n  it(\"fallback version appears in User-Agent header\", async () => {\n    vi.stubGlobal(\"fetch\", vi.fn().mockRejectedValue(new Error(\"timeout\")))\n\n    const { ANTIGRAVITY_VERSION_FALLBACK, getAntigravityHeaders } = await import(\"../constants.ts\")\n    const { initAntigravityVersion } = await import(\"./version.ts\")\n    await initAntigravityVersion()\n\n    const headers = getAntigravityHeaders()\n    expect(headers[\"User-Agent\"]).toContain(`Antigravity/${ANTIGRAVITY_VERSION_FALLBACK}`)\n  })\n\n  it(\"fallback version appears in randomized antigravity headers\", async () => {\n    vi.stubGlobal(\"fetch\", vi.fn().mockRejectedValue(new Error(\"timeout\")))\n\n    const { ANTIGRAVITY_VERSION_FALLBACK, getRandomizedHeaders } = await import(\"../constants.ts\")\n    const { initAntigravityVersion } = await import(\"./version.ts\")\n    await initAntigravityVersion()\n\n    const headers = getRandomizedHeaders(\"antigravity\")\n    expect(headers[\"User-Agent\"]).toContain(ANTIGRAVITY_VERSION_FALLBACK)\n  })\n})\n"
  },
  {
    "path": "src/plugin/version.ts",
    "content": "/**\n * Remote Antigravity version fetcher.\n *\n * Mirrors the Antigravity-Manager's version resolution strategy:\n *   1. Auto-updater API (plain text with semver)\n *   2. Changelog page scrape (first 5000 chars)\n *   3. Hardcoded fallback in constants.ts\n *\n * Called once at plugin startup to ensure headers use the latest\n * supported version, avoiding \"version no longer supported\" errors.\n *\n * @see https://github.com/lbjlaq/Antigravity-Manager (src-tauri/src/constants.rs)\n */\n\nimport { getAntigravityVersion, setAntigravityVersion } from \"../constants\";\nimport { createLogger } from \"./logger\";\n\nconst VERSION_URL = \"https://antigravity-auto-updater-974169037036.us-central1.run.app\";\nconst CHANGELOG_URL = \"https://antigravity.google/changelog\";\nconst FETCH_TIMEOUT_MS = 5000;\nconst CHANGELOG_SCAN_CHARS = 5000;\nconst VERSION_REGEX = /\\d+\\.\\d+\\.\\d+/;\n\ntype VersionSource = \"api\" | \"changelog\" | \"fallback\";\n\nfunction parseVersion(text: string): string | null {\n  const match = text.match(VERSION_REGEX);\n  return match ? match[0] : null;\n}\n\nasync function tryFetchVersion(url: string, maxChars?: number): Promise<string | null> {\n  const controller = new AbortController();\n  const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);\n  try {\n    const response = await fetch(url, { signal: controller.signal });\n    if (!response.ok) return null;\n    let text = await response.text();\n    if (maxChars) text = text.slice(0, maxChars);\n    return parseVersion(text);\n  } catch {\n    return null;\n  } finally {\n    clearTimeout(timeout);\n  }\n}\n\n/**\n * Fetch the latest Antigravity version and update the global constant.\n * Safe to call before logger is initialized (will silently skip logging).\n */\nexport async function initAntigravityVersion(): Promise<void> {\n  const log = createLogger(\"version\");\n  const fallback = getAntigravityVersion();\n  let version: string | null;\n  let source: VersionSource;\n\n  // 1. Try auto-updater API\n  version = await tryFetchVersion(VERSION_URL);\n  if (version) {\n    source = \"api\";\n  } else {\n    // 2. Try changelog page scrape\n    version = await tryFetchVersion(CHANGELOG_URL, CHANGELOG_SCAN_CHARS);\n    if (version) {\n      source = \"changelog\";\n    } else {\n      // 3. Fall back to hardcoded\n      source = \"fallback\";\n      setAntigravityVersion(fallback);\n      log.info(\"version-fetch-failed\", { fallback });\n      return;\n    }\n  }\n\n  if (version !== fallback) {\n    log.info(\"version-updated\", { version, source, previous: fallback });\n  } else {\n    log.debug(\"version-unchanged\", { version, source });\n  }\n  setAntigravityVersion(version);\n}\n"
  },
  {
    "path": "src/plugin.ts",
    "content": "import { exec } from \"node:child_process\";\nimport { tool } from \"@opencode-ai/plugin\";\nimport {\n  ANTIGRAVITY_DEFAULT_PROJECT_ID,\n  ANTIGRAVITY_ENDPOINT_FALLBACKS,\n  ANTIGRAVITY_ENDPOINT_PROD,\n  ANTIGRAVITY_PROVIDER_ID,\n  getAntigravityHeaders,\n  type HeaderStyle,\n} from \"./constants\";\nimport { authorizeAntigravity, exchangeAntigravity } from \"./antigravity/oauth\";\nimport type { AntigravityTokenExchangeResult } from \"./antigravity/oauth\";\nimport { accessTokenExpired, isOAuthAuth, parseRefreshParts, formatRefreshParts } from \"./plugin/auth\";\nimport { promptAddAnotherAccount, promptLoginMode, promptProjectId } from \"./plugin/cli\";\nimport { ensureProjectContext } from \"./plugin/project\";\nimport {\n  startAntigravityDebugRequest, \n  logAntigravityDebugResponse,\n  logAccountContext,\n  logRateLimitEvent,\n  logRateLimitSnapshot,\n  logResponseBody,\n  logModelFamily,\n  isDebugEnabled,\n  getLogFilePath,\n  initializeDebug,\n} from \"./plugin/debug\";\nimport {\n  buildThinkingWarmupBody,\n  isGenerativeLanguageRequest,\n  prepareAntigravityRequest,\n  transformAntigravityResponse,\n} from \"./plugin/request\";\nimport { resolveModelWithTier } from \"./plugin/transform/model-resolver\";\nimport {\n  isEmptyResponseBody,\n  createSyntheticErrorResponse,\n} from \"./plugin/request-helpers\";\nimport { EmptyResponseError } from \"./plugin/errors\";\nimport { AntigravityTokenRefreshError, refreshAccessToken } from \"./plugin/token\";\nimport { startOAuthListener, type OAuthListener } from \"./plugin/server\";\nimport { clearAccounts, loadAccounts, saveAccounts, saveAccountsReplace } from \"./plugin/storage\";\nimport { AccountManager, type ModelFamily, parseRateLimitReason, calculateBackoffMs, computeSoftQuotaCacheTtlMs } from \"./plugin/accounts\";\nimport { createAutoUpdateCheckerHook } from \"./hooks/auto-update-checker\";\nimport { loadConfig, initRuntimeConfig, type AntigravityConfig } from \"./plugin/config\";\nimport { createSessionRecoveryHook, getRecoverySuccessToast } from \"./plugin/recovery\";\nimport { checkAccountsQuota } from \"./plugin/quota\";\nimport { initDiskSignatureCache } from \"./plugin/cache\";\nimport { createProactiveRefreshQueue, type ProactiveRefreshQueue } from \"./plugin/refresh-queue\";\nimport { initLogger, createLogger } from \"./plugin/logger\";\nimport { initHealthTracker, getHealthTracker, initTokenTracker, getTokenTracker } from \"./plugin/rotation\";\nimport { initAntigravityVersion } from \"./plugin/version\";\nimport { executeSearch } from \"./plugin/search\";\nimport type {\n  GetAuth,\n  LoaderResult,\n  PluginClient,\n  PluginContext,\n  PluginResult,\n  ProjectContextResult,\n  Provider,\n} from \"./plugin/types\";\n\nconst MAX_OAUTH_ACCOUNTS = 10;\nconst MAX_WARMUP_SESSIONS = 1000;\nconst MAX_WARMUP_RETRIES = 2;\nconst CAPACITY_BACKOFF_TIERS_MS = [5000, 10000, 20000, 30000, 60000];\n\nfunction getCapacityBackoffDelay(consecutiveFailures: number): number {\n  const index = Math.min(consecutiveFailures, CAPACITY_BACKOFF_TIERS_MS.length - 1);\n  return CAPACITY_BACKOFF_TIERS_MS[Math.max(0, index)] ?? 5000;\n}\nconst warmupAttemptedSessionIds = new Set<string>();\nconst warmupSucceededSessionIds = new Set<string>();\n\n// Track if this plugin instance is running in a child session (subagent, background task)\n// Used to filter toasts based on toast_scope config\nlet isChildSession = false;\nlet childSessionParentID: string | undefined = undefined;\n\nconst log = createLogger(\"plugin\");\n\n// Module-level toast debounce to persist across requests (fixes toast spam)\nconst rateLimitToastCooldowns = new Map<string, number>();\nconst RATE_LIMIT_TOAST_COOLDOWN_MS = 5000;\nconst MAX_TOAST_COOLDOWN_ENTRIES = 100;\n\n// Track if \"all accounts blocked\" toasts were shown to prevent spam in while loop\nlet softQuotaToastShown = false;\nlet rateLimitToastShown = false;\n\n// Module-level reference to AccountManager for access from auth.login\nlet activeAccountManager: import(\"./plugin/accounts\").AccountManager | null = null;\n\nfunction cleanupToastCooldowns(): void {\n  if (rateLimitToastCooldowns.size > MAX_TOAST_COOLDOWN_ENTRIES) {\n    const now = Date.now();\n    for (const [key, time] of rateLimitToastCooldowns) {\n      if (now - time > RATE_LIMIT_TOAST_COOLDOWN_MS * 2) {\n        rateLimitToastCooldowns.delete(key);\n      }\n    }\n  }\n}\n\nfunction shouldShowRateLimitToast(message: string): boolean {\n  cleanupToastCooldowns();\n  const toastKey = message.replace(/\\d+/g, \"X\");\n  const lastShown = rateLimitToastCooldowns.get(toastKey) ?? 0;\n  const now = Date.now();\n  if (now - lastShown < RATE_LIMIT_TOAST_COOLDOWN_MS) {\n    return false;\n  }\n  rateLimitToastCooldowns.set(toastKey, now);\n  return true;\n}\n\nfunction resetAllAccountsBlockedToasts(): void {\n  softQuotaToastShown = false;\n  rateLimitToastShown = false;\n}\n\nconst quotaRefreshInProgressByEmail = new Set<string>();\n\nasync function triggerAsyncQuotaRefreshForAccount(\n  accountManager: AccountManager,\n  accountIndex: number,\n  client: PluginClient,\n  providerId: string,\n  intervalMinutes: number,\n): Promise<void> {\n  if (intervalMinutes <= 0) return;\n  \n  const accounts = accountManager.getAccounts();\n  const account = accounts[accountIndex];\n  if (!account || account.enabled === false) return;\n  \n  const accountKey = account.email ?? `idx-${accountIndex}`;\n  if (quotaRefreshInProgressByEmail.has(accountKey)) return;\n  \n  const intervalMs = intervalMinutes * 60 * 1000;\n  const age = account.cachedQuotaUpdatedAt != null \n    ? Date.now() - account.cachedQuotaUpdatedAt \n    : Infinity;\n  \n  if (age < intervalMs) return;\n  \n  quotaRefreshInProgressByEmail.add(accountKey);\n  \n  try {\n    const accountsForCheck = accountManager.getAccountsForQuotaCheck();\n    const singleAccount = accountsForCheck[accountIndex];\n    if (!singleAccount) {\n      quotaRefreshInProgressByEmail.delete(accountKey);\n      return;\n    }\n    \n    const results = await checkAccountsQuota([singleAccount], client, providerId);\n    \n    if (results[0]?.status === \"ok\" && results[0]?.quota?.groups) {\n      accountManager.updateQuotaCache(accountIndex, results[0].quota.groups);\n      accountManager.requestSaveToDisk();\n    }\n  } catch (err) {\n    log.debug(`quota-refresh-failed email=${accountKey}`, { error: String(err) });\n  } finally {\n    quotaRefreshInProgressByEmail.delete(accountKey);\n  }\n}\n\nfunction trackWarmupAttempt(sessionId: string): boolean {\n  if (warmupSucceededSessionIds.has(sessionId)) {\n    return false;\n  }\n  if (warmupAttemptedSessionIds.size >= MAX_WARMUP_SESSIONS) {\n    const first = warmupAttemptedSessionIds.values().next().value;\n    if (first) {\n      warmupAttemptedSessionIds.delete(first);\n      warmupSucceededSessionIds.delete(first);\n    }\n  }\n  const attempts = getWarmupAttemptCount(sessionId);\n  if (attempts >= MAX_WARMUP_RETRIES) {\n    return false;\n  }\n  warmupAttemptedSessionIds.add(sessionId);\n  return true;\n}\n\nfunction getWarmupAttemptCount(sessionId: string): number {\n  return warmupAttemptedSessionIds.has(sessionId) ? 1 : 0;\n}\n\nfunction markWarmupSuccess(sessionId: string): void {\n  warmupSucceededSessionIds.add(sessionId);\n  if (warmupSucceededSessionIds.size >= MAX_WARMUP_SESSIONS) {\n    const first = warmupSucceededSessionIds.values().next().value;\n    if (first) warmupSucceededSessionIds.delete(first);\n  }\n}\n\nfunction clearWarmupAttempt(sessionId: string): void {\n  warmupAttemptedSessionIds.delete(sessionId);\n}\n\nfunction isWSL(): boolean {\n  if (process.platform !== \"linux\") return false;\n  try {\n    const { readFileSync } = require(\"node:fs\");\n    const release = readFileSync(\"/proc/version\", \"utf8\").toLowerCase();\n    return release.includes(\"microsoft\") || release.includes(\"wsl\");\n  } catch {\n    return false;\n  }\n}\n\nfunction isWSL2(): boolean {\n  if (!isWSL()) return false;\n  try {\n    const { readFileSync } = require(\"node:fs\");\n    const version = readFileSync(\"/proc/version\", \"utf8\").toLowerCase();\n    return version.includes(\"wsl2\") || version.includes(\"microsoft-standard\");\n  } catch {\n    return false;\n  }\n}\n\nfunction isRemoteEnvironment(): boolean {\n  if (process.env.SSH_CLIENT || process.env.SSH_TTY || process.env.SSH_CONNECTION) {\n    return true;\n  }\n  if (process.env.REMOTE_CONTAINERS || process.env.CODESPACES) {\n    return true;\n  }\n  if (process.platform === \"linux\" && !process.env.DISPLAY && !process.env.WAYLAND_DISPLAY && !isWSL()) {\n    return true;\n  }\n  return false;\n}\n\nfunction shouldSkipLocalServer(): boolean {\n  return isWSL2() || isRemoteEnvironment();\n}\n\nasync function openBrowser(url: string): Promise<boolean> {\n  try {\n    if (process.platform === \"darwin\") {\n      exec(`open \"${url}\"`);\n      return true;\n    }\n    if (process.platform === \"win32\") {\n      exec(`start \"\" \"${url}\"`);\n      return true;\n    }\n    if (isWSL()) {\n      try {\n        exec(`wslview \"${url}\"`);\n        return true;\n      } catch {}\n    }\n    if (!process.env.DISPLAY && !process.env.WAYLAND_DISPLAY) {\n      return false;\n    }\n    exec(`xdg-open \"${url}\"`);\n    return true;\n  } catch {\n    return false;\n  }\n}\n\ntype VerificationProbeResult = {\n  status: \"ok\" | \"blocked\" | \"error\";\n  message: string;\n  verifyUrl?: string;\n};\n\nfunction decodeEscapedText(input: string): string {\n  return input\n    .replace(/&amp;/g, \"&\")\n    .replace(/\\\\u([0-9a-fA-F]{4})/g, (_, hex: string) => String.fromCharCode(Number.parseInt(hex, 16)));\n}\n\nfunction normalizeGoogleVerificationUrl(rawUrl: string): string | undefined {\n  const normalized = decodeEscapedText(rawUrl).trim();\n  if (!normalized) {\n    return undefined;\n  }\n  try {\n    const parsed = new URL(normalized);\n    if (parsed.hostname !== \"accounts.google.com\") {\n      return undefined;\n    }\n    return parsed.toString();\n  } catch {\n    return undefined;\n  }\n}\n\nfunction selectBestVerificationUrl(urls: string[]): string | undefined {\n  const unique = Array.from(new Set(urls.map((url) => normalizeGoogleVerificationUrl(url)).filter(Boolean) as string[]));\n  if (unique.length === 0) {\n    return undefined;\n  }\n  unique.sort((a, b) => {\n    const score = (value: string): number => {\n      let total = 0;\n      if (value.includes(\"plt=\")) total += 4;\n      if (value.includes(\"/signin/continue\")) total += 3;\n      if (value.includes(\"continue=\")) total += 2;\n      if (value.includes(\"service=cloudcode\")) total += 1;\n      return total;\n    };\n    return score(b) - score(a);\n  });\n  return unique[0];\n}\n\nfunction extractVerificationErrorDetails(bodyText: string): {\n  validationRequired: boolean;\n  message?: string;\n  verifyUrl?: string;\n} {\n  const decodedBody = decodeEscapedText(bodyText);\n  const lowerBody = decodedBody.toLowerCase();\n  let validationRequired = lowerBody.includes(\"validation_required\");\n  let message: string | undefined;\n  const verificationUrls = new Set<string>();\n\n  const collectUrlsFromText = (text: string): void => {\n    for (const match of text.matchAll(/https:\\/\\/accounts\\.google\\.com\\/[^\\s\"'<>]+/gi)) {\n      if (match[0]) {\n        verificationUrls.add(match[0]);\n      }\n    }\n  };\n\n  collectUrlsFromText(decodedBody);\n\n  const payloads: unknown[] = [];\n  const trimmed = decodedBody.trim();\n  if (trimmed.startsWith(\"{\") || trimmed.startsWith(\"[\")) {\n    try {\n      payloads.push(JSON.parse(trimmed));\n    } catch {\n    }\n  }\n\n  for (const rawLine of decodedBody.split(\"\\n\")) {\n    const line = rawLine.trim();\n    if (!line.startsWith(\"data:\")) {\n      continue;\n    }\n    const payloadText = line.slice(5).trim();\n    if (!payloadText || payloadText === \"[DONE]\") {\n      continue;\n    }\n    try {\n      payloads.push(JSON.parse(payloadText));\n    } catch {\n      collectUrlsFromText(payloadText);\n    }\n  }\n\n  const visited = new Set<unknown>();\n  const walk = (value: unknown, key?: string): void => {\n    if (typeof value === \"string\") {\n      const normalizedValue = decodeEscapedText(value);\n      const lowerValue = normalizedValue.toLowerCase();\n      const lowerKey = key?.toLowerCase() ?? \"\";\n\n      if (lowerValue.includes(\"validation_required\")) {\n        validationRequired = true;\n      }\n      if (\n        !message &&\n        (lowerKey.includes(\"message\") || lowerKey.includes(\"detail\") || lowerKey.includes(\"description\"))\n      ) {\n        message = normalizedValue;\n      }\n      if (\n        lowerKey.includes(\"validation_url\") ||\n        lowerKey.includes(\"verify_url\") ||\n        lowerKey.includes(\"verification_url\") ||\n        lowerKey === \"url\"\n      ) {\n        verificationUrls.add(normalizedValue);\n      }\n      collectUrlsFromText(normalizedValue);\n      return;\n    }\n\n    if (!value || typeof value !== \"object\" || visited.has(value)) {\n      return;\n    }\n\n    visited.add(value);\n\n    if (Array.isArray(value)) {\n      for (const item of value) {\n        walk(item);\n      }\n      return;\n    }\n\n    for (const [childKey, childValue] of Object.entries(value as Record<string, unknown>)) {\n      walk(childValue, childKey);\n    }\n  };\n\n  for (const payload of payloads) {\n    walk(payload);\n  }\n\n  if (!validationRequired) {\n    validationRequired =\n      lowerBody.includes(\"verification required\") ||\n      lowerBody.includes(\"verify your account\") ||\n      lowerBody.includes(\"account verification\");\n  }\n\n  if (!message) {\n    const fallback = decodedBody\n      .split(\"\\n\")\n      .map((line) => line.trim())\n      .find((line) => line && !line.startsWith(\"data:\") && /(verify|validation|required)/i.test(line));\n    if (fallback) {\n      message = fallback;\n    }\n  }\n\n  return {\n    validationRequired,\n    message,\n    verifyUrl: selectBestVerificationUrl([...verificationUrls]),\n  };\n}\n\nasync function verifyAccountAccess(\n  account: {\n    refreshToken: string;\n    email?: string;\n    projectId?: string;\n    managedProjectId?: string;\n  },\n  client: PluginClient,\n  providerId: string,\n): Promise<VerificationProbeResult> {\n  const parsed = parseRefreshParts(account.refreshToken);\n  if (!parsed.refreshToken) {\n    return { status: \"error\", message: \"Missing refresh token for selected account.\" };\n  }\n\n  const auth = {\n    type: \"oauth\" as const,\n    refresh: formatRefreshParts({\n      refreshToken: parsed.refreshToken,\n      projectId: parsed.projectId ?? account.projectId,\n      managedProjectId: parsed.managedProjectId ?? account.managedProjectId,\n    }),\n    access: \"\",\n    expires: 0,\n  };\n\n  let refreshedAuth: Awaited<ReturnType<typeof refreshAccessToken>>;\n  try {\n    refreshedAuth = await refreshAccessToken(auth, client, providerId);\n  } catch (error) {\n    if (error instanceof AntigravityTokenRefreshError) {\n      return { status: \"error\", message: error.message };\n    }\n    return { status: \"error\", message: `Token refresh failed: ${String(error)}` };\n  }\n\n  if (!refreshedAuth?.access) {\n    return { status: \"error\", message: \"Could not refresh access token for this account.\" };\n  }\n\n  const projectId =\n    parsed.managedProjectId ??\n    parsed.projectId ??\n    account.managedProjectId ??\n    account.projectId ??\n    ANTIGRAVITY_DEFAULT_PROJECT_ID;\n\n  const headers: Record<string, string> = {\n    ...getAntigravityHeaders(),\n    Authorization: `Bearer ${refreshedAuth.access}`,\n    \"Content-Type\": \"application/json\",\n  };\n  if (projectId) {\n    headers[\"x-goog-user-project\"] = projectId;\n  }\n\n  const requestBody = {\n    model: \"gemini-3-flash\",\n    request: {\n      model: \"gemini-3-flash\",\n      contents: [{ role: \"user\", parts: [{ text: \"ping\" }] }],\n      generationConfig: { maxOutputTokens: 1, temperature: 0 },\n    },\n  };\n\n  const controller = new AbortController();\n  const timeoutId = setTimeout(() => controller.abort(), 20000);\n\n  let response: Response;\n  try {\n    response = await fetch(`${ANTIGRAVITY_ENDPOINT_PROD}/v1internal:streamGenerateContent?alt=sse`, {\n      method: \"POST\",\n      headers,\n      body: JSON.stringify(requestBody),\n      signal: controller.signal,\n    });\n  } catch (error) {\n    if (error instanceof Error && error.name === \"AbortError\") {\n      return { status: \"error\", message: \"Verification check timed out.\" };\n    }\n    return { status: \"error\", message: `Verification check failed: ${String(error)}` };\n  } finally {\n    clearTimeout(timeoutId);\n  }\n\n  let responseBody = \"\";\n  try {\n    responseBody = await response.text();\n  } catch {\n    responseBody = \"\";\n  }\n\n  if (response.ok) {\n    return { status: \"ok\", message: \"Account verification check passed.\" };\n  }\n\n  const extracted = extractVerificationErrorDetails(responseBody);\n  if (response.status === 403 && extracted.validationRequired) {\n    return {\n      status: \"blocked\",\n      message: extracted.message ?? \"Google requires additional account verification.\",\n      verifyUrl: extracted.verifyUrl,\n    };\n  }\n\n  const fallbackMessage = extracted.message ?? `Request failed (${response.status} ${response.statusText}).`;\n  return {\n    status: \"error\",\n    message: fallbackMessage,\n  };\n}\n\nasync function promptAccountIndexForVerification(\n  accounts: Array<{ email?: string; index: number }>,\n): Promise<number | undefined> {\n  const { createInterface } = await import(\"node:readline/promises\");\n  const { stdin, stdout } = await import(\"node:process\");\n  const rl = createInterface({ input: stdin, output: stdout });\n  try {\n    console.log(\"\\nSelect an account to verify:\");\n    for (const account of accounts) {\n      const label = account.email || `Account ${account.index + 1}`;\n      console.log(`  ${account.index + 1}. ${label}`);\n    }\n    console.log(\"\");\n\n    while (true) {\n      const answer = (await rl.question(\"Account number (leave blank to cancel): \")).trim();\n      if (!answer) {\n        return undefined;\n      }\n      const parsedIndex = Number(answer);\n      if (!Number.isInteger(parsedIndex)) {\n        console.log(\"Please enter a valid account number.\");\n        continue;\n      }\n      const normalizedIndex = parsedIndex - 1;\n      const selected = accounts.find((account) => account.index === normalizedIndex);\n      if (!selected) {\n        console.log(\"Please enter a number from the list above.\");\n        continue;\n      }\n      return selected.index;\n    }\n  } finally {\n    rl.close();\n  }\n}\n\nasync function promptOpenVerificationUrl(): Promise<boolean> {\n  const answer = (await promptOAuthCallbackValue(\"Open verification URL in your browser now? [Y/n]: \")).trim().toLowerCase();\n  return answer === \"\" || answer === \"y\" || answer === \"yes\";\n}\n\ntype VerificationStoredAccount = {\n  enabled?: boolean;\n  verificationRequired?: boolean;\n  verificationRequiredAt?: number;\n  verificationRequiredReason?: string;\n  verificationUrl?: string;\n};\n\nfunction markStoredAccountVerificationRequired(\n  account: VerificationStoredAccount,\n  reason: string,\n  verifyUrl?: string,\n): boolean {\n  let changed = false;\n  const wasVerificationRequired = account.verificationRequired === true;\n\n  if (!wasVerificationRequired) {\n    account.verificationRequired = true;\n    changed = true;\n  }\n\n  if (!wasVerificationRequired || account.verificationRequiredAt === undefined) {\n    account.verificationRequiredAt = Date.now();\n    changed = true;\n  }\n\n  const normalizedReason = reason.trim();\n  if (account.verificationRequiredReason !== normalizedReason) {\n    account.verificationRequiredReason = normalizedReason;\n    changed = true;\n  }\n\n  const normalizedUrl = verifyUrl?.trim();\n  if (normalizedUrl && account.verificationUrl !== normalizedUrl) {\n    account.verificationUrl = normalizedUrl;\n    changed = true;\n  }\n\n  if (account.enabled !== false) {\n    account.enabled = false;\n    changed = true;\n  }\n\n  return changed;\n}\n\nfunction clearStoredAccountVerificationRequired(\n  account: VerificationStoredAccount,\n  enableIfRequired = false,\n): { changed: boolean; wasVerificationRequired: boolean } {\n  const wasVerificationRequired = account.verificationRequired === true;\n  let changed = false;\n\n  if (account.verificationRequired !== false) {\n    account.verificationRequired = false;\n    changed = true;\n  }\n  if (account.verificationRequiredAt !== undefined) {\n    account.verificationRequiredAt = undefined;\n    changed = true;\n  }\n  if (account.verificationRequiredReason !== undefined) {\n    account.verificationRequiredReason = undefined;\n    changed = true;\n  }\n  if (account.verificationUrl !== undefined) {\n    account.verificationUrl = undefined;\n    changed = true;\n  }\n\n  if (enableIfRequired && wasVerificationRequired && account.enabled === false) {\n    account.enabled = true;\n    changed = true;\n  }\n\n  return { changed, wasVerificationRequired };\n}\n\nasync function promptOAuthCallbackValue(message: string): Promise<string> {\n  const { createInterface } = await import(\"node:readline/promises\");\n  const { stdin, stdout } = await import(\"node:process\");\n  const rl = createInterface({ input: stdin, output: stdout });\n  try {\n    return (await rl.question(message)).trim();\n  } finally {\n    rl.close();\n  }\n}\n\ntype OAuthCallbackParams = { code: string; state: string };\n\nfunction getStateFromAuthorizationUrl(authorizationUrl: string): string {\n  try {\n    return new URL(authorizationUrl).searchParams.get(\"state\") ?? \"\";\n  } catch {\n    return \"\";\n  }\n}\n\nfunction extractOAuthCallbackParams(url: URL): OAuthCallbackParams | null {\n  const code = url.searchParams.get(\"code\");\n  const state = url.searchParams.get(\"state\");\n  if (!code || !state) {\n    return null;\n  }\n  return { code, state };\n}\n\nfunction parseOAuthCallbackInput(\n  value: string,\n  fallbackState: string,\n): OAuthCallbackParams | { error: string } {\n  const trimmed = value.trim();\n  if (!trimmed) {\n    return { error: \"Missing authorization code\" };\n  }\n\n  try {\n    const url = new URL(trimmed);\n    const code = url.searchParams.get(\"code\");\n    const state = url.searchParams.get(\"state\") ?? fallbackState;\n\n    if (!code) {\n      return { error: \"Missing code in callback URL\" };\n    }\n    if (!state) {\n      return { error: \"Missing state in callback URL\" };\n    }\n\n    return { code, state };\n  } catch {\n    if (!fallbackState) {\n      return { error: \"Missing state. Paste the full redirect URL instead of only the code.\" };\n    }\n\n    return { code: trimmed, state: fallbackState };\n  }\n}\n\nasync function promptManualOAuthInput(\n  fallbackState: string,\n): Promise<AntigravityTokenExchangeResult> {\n  console.log(\"1. Open the URL above in your browser and complete Google sign-in.\");\n  console.log(\"2. After approving, copy the full redirected localhost URL from the address bar.\");\n  console.log(\"3. Paste it back here.\\n\");\n\n  const callbackInput = await promptOAuthCallbackValue(\n    \"Paste the redirect URL (or just the code) here: \",\n  );\n  const params = parseOAuthCallbackInput(callbackInput, fallbackState);\n  if (\"error\" in params) {\n    return { type: \"failed\", error: params.error };\n  }\n\n  return exchangeAntigravity(params.code, params.state);\n}\n\nfunction clampInt(value: number, min: number, max: number): number {\n  if (!Number.isFinite(value)) {\n    return min;\n  }\n  return Math.min(max, Math.max(min, Math.floor(value)));\n}\n\nasync function persistAccountPool(\n  results: Array<Extract<AntigravityTokenExchangeResult, { type: \"success\" }>>,\n  replaceAll: boolean = false,\n): Promise<void> {\n  if (results.length === 0) {\n    return;\n  }\n\n  const now = Date.now();\n  \n  // If replaceAll is true (fresh login), start with empty accounts\n  // Otherwise, load existing accounts and merge\n  const stored = replaceAll ? null : await loadAccounts();\n  const accounts = stored?.accounts ? [...stored.accounts] : [];\n\n  const indexByRefreshToken = new Map<string, number>();\n  const indexByEmail = new Map<string, number>();\n  for (let i = 0; i < accounts.length; i++) {\n    const acc = accounts[i];\n    if (acc?.refreshToken) {\n      indexByRefreshToken.set(acc.refreshToken, i);\n    }\n    if (acc?.email) {\n      indexByEmail.set(acc.email, i);\n    }\n  }\n\n  for (const result of results) {\n    const parts = parseRefreshParts(result.refresh);\n    if (!parts.refreshToken) {\n      continue;\n    }\n\n    // First, check for existing account by email (prevents duplicates when refresh token changes)\n    // Only use email-based deduplication if the new account has an email\n    const existingByEmail = result.email ? indexByEmail.get(result.email) : undefined;\n    const existingByToken = indexByRefreshToken.get(parts.refreshToken);\n    \n    // Prefer email-based match to handle refresh token rotation\n    const existingIndex = existingByEmail ?? existingByToken;\n    \n    if (existingIndex === undefined) {\n      // New account - add it\n      const newIndex = accounts.length;\n      indexByRefreshToken.set(parts.refreshToken, newIndex);\n      if (result.email) {\n        indexByEmail.set(result.email, newIndex);\n      }\n      accounts.push({\n        email: result.email,\n        refreshToken: parts.refreshToken,\n        projectId: parts.projectId,\n        managedProjectId: parts.managedProjectId,\n        addedAt: now,\n        lastUsed: now,\n        enabled: true,\n      });\n      continue;\n    }\n\n    const existing = accounts[existingIndex];\n    if (!existing) {\n      continue;\n    }\n\n    // Update existing account (this handles both email match and token match cases)\n    // When email matches but token differs, this effectively replaces the old token\n    const oldToken = existing.refreshToken;\n    accounts[existingIndex] = {\n      ...existing,\n      email: result.email ?? existing.email,\n      refreshToken: parts.refreshToken,\n      projectId: parts.projectId ?? existing.projectId,\n      managedProjectId: parts.managedProjectId ?? existing.managedProjectId,\n      lastUsed: now,\n    };\n    \n    // Update the token index if the token changed\n    if (oldToken !== parts.refreshToken) {\n      indexByRefreshToken.delete(oldToken);\n      indexByRefreshToken.set(parts.refreshToken, existingIndex);\n    }\n  }\n\n  if (accounts.length === 0) {\n    return;\n  }\n\n  // For fresh logins, always start at index 0\n  const activeIndex = replaceAll \n    ? 0 \n    : (typeof stored?.activeIndex === \"number\" && Number.isFinite(stored.activeIndex) ? stored.activeIndex : 0);\n\n  await saveAccounts({\n    version: 4,\n    accounts,\n    activeIndex: clampInt(activeIndex, 0, accounts.length - 1),\n    activeIndexByFamily: {\n      claude: clampInt(activeIndex, 0, accounts.length - 1),\n      gemini: clampInt(activeIndex, 0, accounts.length - 1),\n    },\n  });\n}\n\nfunction buildAuthSuccessFromStoredAccount(account: {\n  refreshToken: string;\n  projectId?: string;\n  managedProjectId?: string;\n  email?: string;\n}): Extract<AntigravityTokenExchangeResult, { type: \"success\" }> {\n  const refresh = formatRefreshParts({\n    refreshToken: account.refreshToken,\n    projectId: account.projectId,\n    managedProjectId: account.managedProjectId,\n  });\n\n  return {\n    type: \"success\",\n    refresh,\n    access: \"\",\n    expires: 0,\n    email: account.email,\n    projectId: account.projectId ?? \"\",\n  };\n}\n\nfunction retryAfterMsFromResponse(response: Response, defaultRetryMs: number = 60_000): number {\n  const retryAfterMsHeader = response.headers.get(\"retry-after-ms\");\n  if (retryAfterMsHeader) {\n    const parsed = Number.parseInt(retryAfterMsHeader, 10);\n    if (!Number.isNaN(parsed) && parsed > 0) {\n      return parsed;\n    }\n  }\n\n  const retryAfterHeader = response.headers.get(\"retry-after\");\n  if (retryAfterHeader) {\n    const parsed = Number.parseInt(retryAfterHeader, 10);\n    if (!Number.isNaN(parsed) && parsed > 0) {\n      return parsed * 1000;\n    }\n  }\n\n  return defaultRetryMs;\n}\n\n/**\n * Parse Go-style duration strings to milliseconds.\n * Supports compound durations: \"1h16m0.667s\", \"1.5s\", \"200ms\", \"5m30s\"\n * \n * @param duration - Duration string in Go format\n * @returns Duration in milliseconds, or null if parsing fails\n */\nfunction parseDurationToMs(duration: string): number | null {\n  // Handle simple formats first for backwards compatibility\n  const simpleMatch = duration.match(/^(\\d+(?:\\.\\d+)?)(ms|s|m|h)?$/i);\n  if (simpleMatch) {\n    const value = parseFloat(simpleMatch[1]!);\n    const unit = (simpleMatch[2] || \"s\").toLowerCase();\n    switch (unit) {\n      case \"h\": return value * 3600 * 1000;\n      case \"m\": return value * 60 * 1000;\n      case \"s\": return value * 1000;\n      case \"ms\": return value;\n      default: return value * 1000;\n    }\n  }\n  \n  // Parse compound Go-style durations: \"1h16m0.667s\", \"5m30s\", etc.\n  const compoundRegex = /(\\d+(?:\\.\\d+)?)(h|m(?!s)|s|ms)/gi;\n  let totalMs = 0;\n  let matchFound = false;\n  let match;\n  \n  while ((match = compoundRegex.exec(duration)) !== null) {\n    matchFound = true;\n    const value = parseFloat(match[1]!);\n    const unit = match[2]!.toLowerCase();\n    switch (unit) {\n      case \"h\": totalMs += value * 3600 * 1000; break;\n      case \"m\": totalMs += value * 60 * 1000; break;\n      case \"s\": totalMs += value * 1000; break;\n      case \"ms\": totalMs += value; break;\n    }\n  }\n  \n  return matchFound ? totalMs : null;\n}\n\ninterface RateLimitBodyInfo {\n  retryDelayMs: number | null;\n  message?: string;\n  quotaResetTime?: string;\n  reason?: string;\n}\n\nfunction extractRateLimitBodyInfo(body: unknown): RateLimitBodyInfo {\n  if (!body || typeof body !== \"object\") {\n    return { retryDelayMs: null };\n  }\n\n  const error = (body as { error?: unknown }).error;\n  const message = error && typeof error === \"object\" \n    ? (error as { message?: string }).message \n    : undefined;\n\n  const details = error && typeof error === \"object\" \n    ? (error as { details?: unknown[] }).details \n    : undefined;\n\n  let reason: string | undefined;\n  if (Array.isArray(details)) {\n    for (const detail of details) {\n      if (!detail || typeof detail !== \"object\") continue;\n      const type = (detail as { \"@type\"?: string })[\"@type\"];\n      if (typeof type === \"string\" && type.includes(\"google.rpc.ErrorInfo\")) {\n        const detailReason = (detail as { reason?: string }).reason;\n        if (typeof detailReason === \"string\") {\n          reason = detailReason;\n          break;\n        }\n      }\n    }\n\n    for (const detail of details) {\n      if (!detail || typeof detail !== \"object\") continue;\n      const type = (detail as { \"@type\"?: string })[\"@type\"];\n      if (typeof type === \"string\" && type.includes(\"google.rpc.RetryInfo\")) {\n        const retryDelay = (detail as { retryDelay?: string }).retryDelay;\n        if (typeof retryDelay === \"string\") {\n          const retryDelayMs = parseDurationToMs(retryDelay);\n          if (retryDelayMs !== null) {\n            return { retryDelayMs, message, reason };\n          }\n        }\n      }\n    }\n\n    for (const detail of details) {\n      if (!detail || typeof detail !== \"object\") continue;\n      const metadata = (detail as { metadata?: Record<string, string> }).metadata;\n      if (metadata && typeof metadata === \"object\") {\n        const quotaResetDelay = metadata.quotaResetDelay;\n        const quotaResetTime = metadata.quotaResetTimeStamp;\n        if (typeof quotaResetDelay === \"string\") {\n          const quotaResetDelayMs = parseDurationToMs(quotaResetDelay);\n          if (quotaResetDelayMs !== null) {\n            return { retryDelayMs: quotaResetDelayMs, message, quotaResetTime, reason };\n          }\n        }\n      }\n    }\n  }\n\n  if (message) {\n    const afterMatch = message.match(/reset after\\s+([0-9hms.]+)/i);\n    const rawDuration = afterMatch?.[1];\n    if (rawDuration) {\n      const parsed = parseDurationToMs(rawDuration);\n      if (parsed !== null) {\n        return { retryDelayMs: parsed, message, reason };\n      }\n    }\n  }\n\n  return { retryDelayMs: null, message, reason };\n}\n\nasync function extractRetryInfoFromBody(response: Response): Promise<RateLimitBodyInfo> {\n  try {\n    const text = await response.clone().text();\n    try {\n      const parsed = JSON.parse(text) as unknown;\n      return extractRateLimitBodyInfo(parsed);\n    } catch {\n      return { retryDelayMs: null };\n    }\n  } catch {\n    return { retryDelayMs: null };\n  }\n}\n\nfunction formatWaitTime(ms: number): string {\n  if (ms < 1000) return `${ms}ms`;\n  const seconds = Math.ceil(ms / 1000);\n  if (seconds < 60) return `${seconds}s`;\n  const minutes = Math.floor(seconds / 60);\n  const remainingSeconds = seconds % 60;\n  if (minutes < 60) {\n    return remainingSeconds > 0 ? `${minutes}m ${remainingSeconds}s` : `${minutes}m`;\n  }\n  const hours = Math.floor(minutes / 60);\n  const remainingMinutes = minutes % 60;\n  return remainingMinutes > 0 ? `${hours}h ${remainingMinutes}m` : `${hours}h`;\n}\n\n// Progressive rate limit retry delays\nconst FIRST_RETRY_DELAY_MS = 1000;      // 1s - first 429 quick retry on same account\nconst SWITCH_ACCOUNT_DELAY_MS = 5000;   // 5s - delay before switching to another account\n\n/**\n * Rate limit state tracking with time-window deduplication.\n * \n * Problem: When multiple subagents hit 429 simultaneously, each would increment\n * the consecutive counter, causing incorrect exponential backoff (5 concurrent\n * 429s = 2^5 backoff instead of 2^1).\n * \n * Solution: Track per account+quota with deduplication window. Multiple 429s\n * within RATE_LIMIT_DEDUP_WINDOW_MS are treated as a single event.\n */\nconst RATE_LIMIT_DEDUP_WINDOW_MS = 2000; // 2 seconds - concurrent requests within this window are deduplicated\nconst RATE_LIMIT_STATE_RESET_MS = 120_000; // Reset consecutive counter after 2 minutes of no 429s\n\ninterface RateLimitState {\n  consecutive429: number;\n  lastAt: number;\n  quotaKey: string; // Track which quota this state is for\n}\n\n// Key format: `${accountIndex}:${quotaKey}` for per-account-per-quota tracking\nconst rateLimitStateByAccountQuota = new Map<string, RateLimitState>();\n\n// Track empty response retry attempts (ported from LLM-API-Key-Proxy)\nconst emptyResponseAttempts = new Map<string, number>();\n\n/**\n * Get rate limit backoff with time-window deduplication.\n * \n * @param accountIndex - The account index\n * @param quotaKey - The quota key (e.g., \"gemini-cli\", \"gemini-antigravity\", \"claude\")\n * @param serverRetryAfterMs - Server-provided retry delay (if any)\n * @param maxBackoffMs - Maximum backoff delay in milliseconds (default 60000)\n * @returns { attempt, delayMs, isDuplicate } - isDuplicate=true if within dedup window\n */\nfunction getRateLimitBackoff(\n  accountIndex: number, \n  quotaKey: string,\n  serverRetryAfterMs: number | null,\n  maxBackoffMs: number = 60_000\n): { attempt: number; delayMs: number; isDuplicate: boolean } {\n  const now = Date.now();\n  const stateKey = `${accountIndex}:${quotaKey}`;\n  const previous = rateLimitStateByAccountQuota.get(stateKey);\n  \n  // Check if this is a duplicate 429 within the dedup window\n  if (previous && (now - previous.lastAt < RATE_LIMIT_DEDUP_WINDOW_MS)) {\n    // Same rate limit event from concurrent request - don't increment\n    const baseDelay = serverRetryAfterMs ?? 1000;\n    const backoffDelay = Math.min(baseDelay * Math.pow(2, previous.consecutive429 - 1), maxBackoffMs);\n    return { \n      attempt: previous.consecutive429, \n      delayMs: Math.max(baseDelay, backoffDelay),\n      isDuplicate: true \n    };\n  }\n  \n  // Check if we should reset (no 429 for 2 minutes) or increment\n  const attempt = previous && (now - previous.lastAt < RATE_LIMIT_STATE_RESET_MS) \n    ? previous.consecutive429 + 1 \n    : 1;\n  \n  rateLimitStateByAccountQuota.set(stateKey, { \n    consecutive429: attempt, \n    lastAt: now,\n    quotaKey \n  });\n  \n  const baseDelay = serverRetryAfterMs ?? 1000;\n  const backoffDelay = Math.min(baseDelay * Math.pow(2, attempt - 1), maxBackoffMs);\n  return { attempt, delayMs: Math.max(baseDelay, backoffDelay), isDuplicate: false };\n}\n\n/**\n * Reset rate limit state for an account+quota combination.\n * Only resets the specific quota, not all quotas for the account.\n */\nfunction resetRateLimitState(accountIndex: number, quotaKey: string): void {\n  const stateKey = `${accountIndex}:${quotaKey}`;\n  rateLimitStateByAccountQuota.delete(stateKey);\n}\n\n/**\n * Reset all rate limit state for an account (all quotas).\n * Used when account is completely healthy.\n */\nfunction resetAllRateLimitStateForAccount(accountIndex: number): void {\n  for (const key of rateLimitStateByAccountQuota.keys()) {\n    if (key.startsWith(`${accountIndex}:`)) {\n      rateLimitStateByAccountQuota.delete(key);\n    }\n  }\n}\n\nfunction headerStyleToQuotaKey(headerStyle: HeaderStyle, family: ModelFamily): string {\n  if (family === \"claude\") return \"claude\";\n  return headerStyle === \"antigravity\" ? \"gemini-antigravity\" : \"gemini-cli\";\n}\n\n// Track consecutive non-429 failures per account to prevent infinite loops\nconst accountFailureState = new Map<number, { consecutiveFailures: number; lastFailureAt: number }>();\nconst MAX_CONSECUTIVE_FAILURES = 5;\nconst FAILURE_COOLDOWN_MS = 30_000; // 30 seconds cooldown after max failures\nconst FAILURE_STATE_RESET_MS = 120_000; // Reset failure count after 2 minutes of no failures\n\nfunction trackAccountFailure(accountIndex: number): { failures: number; shouldCooldown: boolean; cooldownMs: number } {\n  const now = Date.now();\n  const previous = accountFailureState.get(accountIndex);\n  \n  // Reset if last failure was more than 2 minutes ago\n  const failures = previous && (now - previous.lastFailureAt < FAILURE_STATE_RESET_MS) \n    ? previous.consecutiveFailures + 1 \n    : 1;\n  \n  accountFailureState.set(accountIndex, { consecutiveFailures: failures, lastFailureAt: now });\n  \n  const shouldCooldown = failures >= MAX_CONSECUTIVE_FAILURES;\n  const cooldownMs = shouldCooldown ? FAILURE_COOLDOWN_MS : 0;\n  \n  return { failures, shouldCooldown, cooldownMs };\n}\n\nfunction resetAccountFailureState(accountIndex: number): void {\n  accountFailureState.delete(accountIndex);\n}\n\n/**\n * Sleep for a given number of milliseconds, respecting an abort signal.\n */\nfunction sleep(ms: number, signal?: AbortSignal | null): Promise<void> {\n  return new Promise((resolve, reject) => {\n    if (signal?.aborted) {\n      reject(signal.reason instanceof Error ? signal.reason : new Error(\"Aborted\"));\n      return;\n    }\n\n    const timeout = setTimeout(() => {\n      cleanup();\n      resolve();\n    }, ms);\n\n    const onAbort = () => {\n      cleanup();\n      reject(signal?.reason instanceof Error ? signal.reason : new Error(\"Aborted\"));\n    };\n\n    const cleanup = () => {\n      clearTimeout(timeout);\n      signal?.removeEventListener(\"abort\", onAbort);\n    };\n\n    signal?.addEventListener(\"abort\", onAbort, { once: true });\n  });\n}\n\n/**\n * Creates an Antigravity OAuth plugin for a specific provider ID.\n */\nexport const createAntigravityPlugin = (providerId: string) => async (\n  { client, directory }: PluginContext,\n): Promise<PluginResult> => {\n  // Load configuration from files and environment variables\n  const config = loadConfig(directory);\n  initRuntimeConfig(config);\n\n  // Cached getAuth function for tool access\n  let cachedGetAuth: GetAuth | null = null;\n  \n  // Initialize debug with config\n  initializeDebug(config);\n  \n  // Initialize structured logger for TUI integration\n  initLogger(client);\n  \n  // Fetch latest Antigravity version from remote API (non-blocking, falls back to hardcoded)\n  await initAntigravityVersion();\n  \n  // Initialize health tracker for hybrid strategy\n  if (config.health_score) {\n    initHealthTracker({\n      initial: config.health_score.initial,\n      successReward: config.health_score.success_reward,\n      rateLimitPenalty: config.health_score.rate_limit_penalty,\n      failurePenalty: config.health_score.failure_penalty,\n      recoveryRatePerHour: config.health_score.recovery_rate_per_hour,\n      minUsable: config.health_score.min_usable,\n      maxScore: config.health_score.max_score,\n    });\n  }\n\n  // Initialize token tracker for hybrid strategy\n  if (config.token_bucket) {\n    initTokenTracker({\n      maxTokens: config.token_bucket.max_tokens,\n      regenerationRatePerMinute: config.token_bucket.regeneration_rate_per_minute,\n      initialTokens: config.token_bucket.initial_tokens,\n    });\n  }\n  \n  // Initialize disk signature cache if keep_thinking is enabled\n  // This integrates with the in-memory cacheSignature/getCachedSignature functions\n  if (config.keep_thinking) {\n    initDiskSignatureCache(config.signature_cache);\n  }\n  \n  // Initialize session recovery hook with full context\n  const sessionRecovery = createSessionRecoveryHook({ client, directory }, config);\n  \n  const updateChecker = createAutoUpdateCheckerHook(client, directory, {\n    showStartupToast: true,\n    autoUpdate: config.auto_update,\n  });\n\n  // Event handler for session recovery and updates\n  const eventHandler = async (input: { event: { type: string; properties?: unknown } }) => {\n    // Forward to update checker\n    await updateChecker.event(input);\n    \n    // Track if this is a child session (subagent, background task)\n    // This is used to filter toasts based on toast_scope config\n    if (input.event.type === \"session.created\") {\n      const props = input.event.properties as { info?: { parentID?: string } } | undefined;\n      if (props?.info?.parentID) {\n        isChildSession = true;\n        childSessionParentID = props.info.parentID;\n        log.debug(\"child-session-detected\", { parentID: props.info.parentID });\n      } else {\n        // Reset for root sessions - important when plugin instance is reused\n        isChildSession = false;\n        childSessionParentID = undefined;\n        log.debug(\"root-session-detected\", {});\n      }\n    }\n    \n    // Handle session recovery\n    if (sessionRecovery && input.event.type === \"session.error\") {\n      const props = input.event.properties as Record<string, unknown> | undefined;\n      const sessionID = props?.sessionID as string | undefined;\n      const messageID = props?.messageID as string | undefined;\n      const error = props?.error;\n      \n      if (sessionRecovery.isRecoverableError(error)) {\n        const messageInfo = {\n          id: messageID,\n          role: \"assistant\" as const,\n          sessionID,\n          error,\n        };\n        \n        // handleSessionRecovery now does the actual fix (injects tool_result, etc.)\n        const recovered = await sessionRecovery.handleSessionRecovery(messageInfo);\n\n        // Only send \"continue\" AFTER successful tool_result_missing recovery\n        // (thinking recoveries already resume inside handleSessionRecovery)\n        if (recovered && sessionID && config.auto_resume) {\n          // For tool_result_missing, we need to send continue after injecting tool_results\n          await client.session.prompt({\n            path: { id: sessionID },\n            body: { parts: [{ type: \"text\", text: config.resume_text }] },\n            query: { directory },\n          }).catch(() => {});\n          \n          // Show success toast (respects toast_scope for child sessions)\n          const successToast = getRecoverySuccessToast();\n          log.debug(\"recovery-toast\", { ...successToast, isChildSession, toastScope: config.toast_scope });\n          if (!(config.toast_scope === \"root_only\" && isChildSession)) {\n            await client.tui.showToast({\n              body: {\n                title: successToast.title,\n                message: successToast.message,\n                variant: \"success\",\n              },\n            }).catch(() => {});\n          }\n        }\n      }\n    }\n  };\n\n  // Create google_search tool with access to auth context\n  const googleSearchTool = tool({\n    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.\",\n    args: {\n      query: tool.schema.string().describe(\"The search query or question to answer using web search\"),\n      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.\"),\n      thinking: tool.schema.boolean().optional().default(true).describe(\"Enable deep thinking for more thorough analysis (default: true)\"),\n    },\n    async execute(args, ctx) {\n      log.debug(\"Google Search tool called\", { query: args.query, urlCount: args.urls?.length ?? 0 });\n\n      // Get current auth context\n      const auth = cachedGetAuth ? await cachedGetAuth() : null;\n      if (!auth || !isOAuthAuth(auth)) {\n        return \"Error: Not authenticated with Antigravity. Please run `opencode auth login` to authenticate.\";\n      }\n\n      // Get access token and project ID\n      const parts = parseRefreshParts(auth.refresh);\n      const projectId = parts.managedProjectId || parts.projectId || \"unknown\";\n\n      // Ensure we have a valid access token\n      let accessToken = auth.access;\n      if (!accessToken || accessTokenExpired(auth)) {\n        try {\n          const refreshed = await refreshAccessToken(auth, client, providerId);\n          accessToken = refreshed?.access;\n        } catch (error) {\n          return `Error: Failed to refresh access token: ${error instanceof Error ? error.message : String(error)}`;\n        }\n      }\n\n      if (!accessToken) {\n        return \"Error: No valid access token available. Please run `opencode auth login` to re-authenticate.\";\n      }\n\n      return executeSearch(\n        {\n          query: args.query,\n          urls: args.urls,\n          thinking: args.thinking,\n        },\n        accessToken,\n        projectId,\n        ctx.abort,\n      );\n    },\n  });\n\n  return {\n    event: eventHandler,\n    tool: {\n      google_search: googleSearchTool,\n    },\n    auth: {\n    provider: providerId,\n    loader: async (getAuth: GetAuth, provider: Provider): Promise<LoaderResult | Record<string, unknown>> => {\n      // Cache getAuth for tool access\n      cachedGetAuth = getAuth;\n\n      const auth = await getAuth();\n      \n      // If OpenCode has no valid OAuth auth, clear any stale account storage\n      if (!isOAuthAuth(auth)) {\n        try {\n          await clearAccounts();\n        } catch {\n          // ignore\n        }\n        return {};\n      }\n\n      // Validate that stored accounts are in sync with OpenCode's auth\n      // If OpenCode's refresh token doesn't match any stored account, clear stale storage\n      const authParts = parseRefreshParts(auth.refresh);\n      const storedAccounts = await loadAccounts();\n      \n      // Note: AccountManager now ensures the current auth is always included in accounts\n\n      const accountManager = await AccountManager.loadFromDisk(auth);\n      activeAccountManager = accountManager;\n      if (accountManager.getAccountCount() > 0) {\n        accountManager.requestSaveToDisk();\n      }\n\n      // Initialize proactive token refresh queue (ported from LLM-API-Key-Proxy)\n      let refreshQueue: ProactiveRefreshQueue | null = null;\n      if (config.proactive_token_refresh && accountManager.getAccountCount() > 0) {\n        refreshQueue = createProactiveRefreshQueue(client, providerId, {\n          enabled: config.proactive_token_refresh,\n          bufferSeconds: config.proactive_refresh_buffer_seconds,\n          checkIntervalSeconds: config.proactive_refresh_check_interval_seconds,\n        });\n        refreshQueue.setAccountManager(accountManager);\n        refreshQueue.start();\n      }\n\n      if (isDebugEnabled()) {\n        const logPath = getLogFilePath();\n        if (logPath) {\n          try {\n            await client.tui.showToast({\n              body: { message: `Debug log: ${logPath}`, variant: \"info\" },\n            });\n          } catch {\n            // TUI may not be available\n          }\n        }\n      }\n\n      if (provider.models) {\n        for (const model of Object.values(provider.models)) {\n          if (model) {\n            model.cost = { input: 0, output: 0 };\n          }\n        }\n      }\n\n      return {\n        apiKey: \"\",\n        async fetch(input, init) {\n          if (!isGenerativeLanguageRequest(input)) {\n            return fetch(input, init);\n          }\n\n          const latestAuth = await getAuth();\n          if (!isOAuthAuth(latestAuth)) {\n            return fetch(input, init);\n          }\n\n          if (accountManager.getAccountCount() === 0) {\n            throw new Error(\"No Antigravity accounts configured. Run `opencode auth login`.\");\n          }\n\n          const urlString = toUrlString(input);\n          const family = getModelFamilyFromUrl(urlString);\n          const model = extractModelFromUrl(urlString);\n          const debugLines: string[] = [];\n          const pushDebug = (line: string) => {\n            if (!isDebugEnabled()) return;\n            debugLines.push(line);\n          };\n          pushDebug(`request=${urlString}`);\n\n          type FailureContext = {\n            response: Response;\n            streaming: boolean;\n            debugContext: ReturnType<typeof startAntigravityDebugRequest>;\n            requestedModel?: string;\n            projectId?: string;\n            endpoint?: string;\n            effectiveModel?: string;\n            sessionId?: string;\n            toolDebugMissing?: number;\n            toolDebugSummary?: string;\n            toolDebugPayload?: string;\n          };\n\n          let lastFailure: FailureContext | null = null;\n          let lastError: Error | null = null;\n          const abortSignal = init?.signal ?? undefined;\n\n          // Helper to check if request was aborted\n          const checkAborted = () => {\n            if (abortSignal?.aborted) {\n              throw abortSignal.reason instanceof Error ? abortSignal.reason : new Error(\"Aborted\");\n            }\n          };\n\n          // Use while(true) loop to handle rate limits with backoff\n          // This ensures we wait and retry when all accounts are rate-limited\n          const quietMode = config.quiet_mode;\n          const toastScope = config.toast_scope;\n\n          // Helper to show toast without blocking on abort (respects quiet_mode and toast_scope)\n          const showToast = async (message: string, variant: \"info\" | \"warning\" | \"success\" | \"error\") => {\n            // Always log to debug regardless of toast filtering\n            log.debug(\"toast\", { message, variant, isChildSession, toastScope });\n            \n            if (quietMode) return;\n            if (abortSignal?.aborted) return;\n            \n            // Filter toasts for child sessions when toast_scope is \"root_only\"\n            if (toastScope === \"root_only\" && isChildSession) {\n              log.debug(\"toast-suppressed-child-session\", { message, variant, parentID: childSessionParentID });\n              return;\n            }\n            \n            if (variant === \"warning\" && message.toLowerCase().includes(\"rate\")) {\n              if (!shouldShowRateLimitToast(message)) {\n                return;\n              }\n            }\n            \n            try {\n              await client.tui.showToast({\n                body: { message, variant },\n              });\n            } catch {\n              // TUI may not be available\n            }\n          };\n          \n          const hasOtherAccountWithAntigravity = (currentAccount: any): boolean => {\n            if (family !== \"gemini\") return false;\n            // Use AccountManager method which properly checks for disabled/cooling-down accounts\n            return accountManager.hasOtherAccountWithAntigravityAvailable(currentAccount.index, family, model);\n          };\n\n          while (true) {\n            // Check for abort at the start of each iteration\n            checkAborted();\n            \n            const accountCount = accountManager.getAccountCount();\n            const routingDecision = resolveHeaderRoutingDecision(urlString, family, config);\n            const {\n              cliFirst,\n              preferredHeaderStyle,\n              explicitQuota,\n              allowQuotaFallback,\n            } = routingDecision;\n            \n            if (accountCount === 0) {\n              throw new Error(\"No Antigravity accounts available. Run `opencode auth login`.\");\n            }\n\n            const softQuotaCacheTtlMs = computeSoftQuotaCacheTtlMs(\n              config.soft_quota_cache_ttl_minutes,\n              config.quota_refresh_interval_minutes,\n            );\n\n            let account = accountManager.getCurrentOrNextForFamily(\n              family, \n              model, \n              config.account_selection_strategy,\n              preferredHeaderStyle,\n              config.pid_offset_enabled,\n              config.soft_quota_threshold_percent,\n              softQuotaCacheTtlMs,\n            );\n\n            if (!account && allowQuotaFallback) {\n              const alternateHeaderStyle: HeaderStyle =\n                preferredHeaderStyle === \"antigravity\" ? \"gemini-cli\" : \"antigravity\";\n              account = accountManager.getCurrentOrNextForFamily(\n                family,\n                model,\n                config.account_selection_strategy,\n                alternateHeaderStyle,\n                config.pid_offset_enabled,\n                config.soft_quota_threshold_percent,\n                softQuotaCacheTtlMs,\n              );\n              if (account) {\n                pushDebug(\n                  `selected-by-fallback idx=${account.index} preferred=${preferredHeaderStyle} alternate=${alternateHeaderStyle}`,\n                );\n              }\n            }\n            \n            if (!account) {\n              if (accountManager.areAllAccountsOverSoftQuota(family, config.soft_quota_threshold_percent, softQuotaCacheTtlMs, model)) {\n                const threshold = config.soft_quota_threshold_percent;\n                const softQuotaWaitMs = accountManager.getMinWaitTimeForSoftQuota(family, threshold, softQuotaCacheTtlMs, model);\n                const maxWaitMs = (config.max_rate_limit_wait_seconds ?? 300) * 1000;\n                \n                if (softQuotaWaitMs === null || (maxWaitMs > 0 && softQuotaWaitMs > maxWaitMs)) {\n                  const waitTimeFormatted = softQuotaWaitMs ? formatWaitTime(softQuotaWaitMs) : \"unknown\";\n                  await showToast(\n                    `All accounts over ${threshold}% quota threshold. Resets in ${waitTimeFormatted}.`,\n                    \"error\"\n                  );\n                  throw new Error(\n                    `Quota protection: All ${accountCount} account(s) are over ${threshold}% usage for ${family}. ` +\n                    `Quota resets in ${waitTimeFormatted}. ` +\n                    `Add more accounts, wait for quota reset, or set soft_quota_threshold_percent: 100 to disable.`\n                  );\n                }\n                \n                const waitSecValue = Math.max(1, Math.ceil(softQuotaWaitMs / 1000));\n                pushDebug(`all-over-soft-quota family=${family} accounts=${accountCount} waitMs=${softQuotaWaitMs}`);\n                \n                if (!softQuotaToastShown) {\n                  await showToast(`All ${accountCount} account(s) over ${threshold}% quota. Waiting ${formatWaitTime(softQuotaWaitMs)}...`, \"warning\");\n                  softQuotaToastShown = true;\n                }\n                \n                await sleep(softQuotaWaitMs, abortSignal);\n                continue;\n              }\n\n              const strictWait = !allowQuotaFallback;\n              // All accounts are rate-limited - wait and retry\n              const waitMs = accountManager.getMinWaitTimeForFamily(\n                family,\n                model,\n                preferredHeaderStyle,\n                strictWait,\n              ) || 60_000;\n              const waitSecValue = Math.max(1, Math.ceil(waitMs / 1000));\n\n              pushDebug(`all-rate-limited family=${family} accounts=${accountCount} waitMs=${waitMs}`);\n              if (isDebugEnabled()) {\n                logAccountContext(\"All accounts rate-limited\", {\n                  index: -1,\n                  family,\n                  totalAccounts: accountCount,\n                });\n                logRateLimitSnapshot(family, accountManager.getAccountsSnapshot());\n              }\n\n              // If wait time exceeds max threshold, return error immediately instead of hanging\n              // 0 means disabled (wait indefinitely)\n              const maxWaitMs = (config.max_rate_limit_wait_seconds ?? 300) * 1000;\n              if (maxWaitMs > 0 && waitMs > maxWaitMs) {\n                const waitTimeFormatted = formatWaitTime(waitMs);\n                await showToast(\n                  `Rate limited for ${waitTimeFormatted}. Try again later or add another account.`,\n                  \"error\"\n                );\n                \n                // Return a proper rate limit error response\n                throw new Error(\n                  `All ${accountCount} account(s) rate-limited for ${family}. ` +\n                  `Quota resets in ${waitTimeFormatted}. ` +\n                  `Add more accounts with \\`opencode auth login\\` or wait and retry.`\n                );\n              }\n\n              if (!rateLimitToastShown) {\n                await showToast(`All ${accountCount} account(s) rate-limited for ${family}. Waiting ${waitSecValue}s...`, \"warning\");\n                rateLimitToastShown = true;\n              }\n\n              // Wait for the rate-limit cooldown to expire, then retry\n              await sleep(waitMs, abortSignal);\n              continue;\n            }\n\n            // Account is available - reset the toast flag\n            resetAllAccountsBlockedToasts();\n\n            pushDebug(\n              `selected idx=${account.index} email=${account.email ?? \"\"} family=${family} accounts=${accountCount} strategy=${config.account_selection_strategy}`,\n            );\n            if (isDebugEnabled()) {\n              logAccountContext(\"Selected\", {\n                index: account.index,\n                email: account.email,\n                family,\n                totalAccounts: accountCount,\n                rateLimitState: account.rateLimitResetTimes,\n              });\n            }\n\n            // Show toast when switching to a different account (debounced, quiet_mode handled by showToast)\n            if (accountCount > 1 && accountManager.shouldShowAccountToast(account.index)) {\n              const accountLabel = account.email || `Account ${account.index + 1}`;\n              // Calculate position among enabled accounts (not absolute index)\n              const enabledAccounts = accountManager.getEnabledAccounts();\n              const enabledPosition = enabledAccounts.findIndex(a => a.index === account.index) + 1;\n              await showToast(\n                `Using ${accountLabel} (${enabledPosition}/${accountCount})`,\n                \"info\"\n              );\n              accountManager.markToastShown(account.index);\n            }\n\n            accountManager.requestSaveToDisk();\n\n            let authRecord = accountManager.toAuthDetails(account);\n\n            if (accessTokenExpired(authRecord)) {\n              try {\n                const refreshed = await refreshAccessToken(authRecord, client, providerId);\n                if (!refreshed) {\n                  const { failures, shouldCooldown, cooldownMs } = trackAccountFailure(account.index);\n                  getHealthTracker().recordFailure(account.index);\n                  lastError = new Error(\"Antigravity token refresh failed\");\n                  if (shouldCooldown) {\n                    accountManager.markAccountCoolingDown(account, cooldownMs, \"auth-failure\");\n                    accountManager.markRateLimited(account, cooldownMs, family, \"antigravity\", model);\n                    pushDebug(`token-refresh-failed: cooldown ${cooldownMs}ms after ${failures} failures`);\n                  }\n                  continue;\n                }\n                resetAccountFailureState(account.index);\n                accountManager.updateFromAuth(account, refreshed);\n                authRecord = refreshed;\n                try {\n                  await accountManager.saveToDisk();\n                } catch (error) {\n                  log.error(\"Failed to persist refreshed auth\", { error: String(error) });\n                }\n              } catch (error) {\n                if (error instanceof AntigravityTokenRefreshError && error.code === \"invalid_grant\") {\n                  const removed = accountManager.removeAccount(account);\n                  if (removed) {\n                    log.warn(\"Removed revoked account from pool - reauthenticate via `opencode auth login`\");\n                    try {\n                      await accountManager.saveToDisk();\n                    } catch (persistError) {\n                      log.error(\"Failed to persist revoked account removal\", { error: String(persistError) });\n                    }\n                  }\n\n                  if (accountManager.getAccountCount() === 0) {\n                    try {\n                      await client.auth.set({\n                        path: { id: providerId },\n                        body: { type: \"oauth\", refresh: \"\", access: \"\", expires: 0 },\n                      });\n                    } catch (storeError) {\n                      log.error(\"Failed to clear stored Antigravity OAuth credentials\", { error: String(storeError) });\n                    }\n\n                    throw new Error(\n                      \"All Antigravity accounts have invalid refresh tokens. Run `opencode auth login` and reauthenticate.\",\n                    );\n                  }\n\n                  lastError = error;\n                  continue;\n                }\n\n                const { failures, shouldCooldown, cooldownMs } = trackAccountFailure(account.index);\n                getHealthTracker().recordFailure(account.index);\n                lastError = error instanceof Error ? error : new Error(String(error));\n                if (shouldCooldown) {\n                  accountManager.markAccountCoolingDown(account, cooldownMs, \"auth-failure\");\n                  accountManager.markRateLimited(account, cooldownMs, family, \"antigravity\", model);\n                  pushDebug(`token-refresh-error: cooldown ${cooldownMs}ms after ${failures} failures`);\n                }\n                continue;\n              }\n            }\n\n            const accessToken = authRecord.access;\n            if (!accessToken) {\n              lastError = new Error(\"Missing access token\");\n              if (accountCount <= 1) {\n                throw lastError;\n              }\n              continue;\n            }\n\n            let projectContext: ProjectContextResult;\n            try {\n              projectContext = await ensureProjectContext(authRecord);\n              resetAccountFailureState(account.index);\n            } catch (error) {\n              const { failures, shouldCooldown, cooldownMs } = trackAccountFailure(account.index);\n              getHealthTracker().recordFailure(account.index);\n              lastError = error instanceof Error ? error : new Error(String(error));\n              if (shouldCooldown) {\n                accountManager.markAccountCoolingDown(account, cooldownMs, \"project-error\");\n                accountManager.markRateLimited(account, cooldownMs, family, \"antigravity\", model);\n                pushDebug(`project-context-error: cooldown ${cooldownMs}ms after ${failures} failures`);\n              }\n              continue;\n            }\n\n            if (projectContext.auth.refresh !== authRecord.refresh || \n                projectContext.auth.access !== authRecord.access) {\n              accountManager.updateFromAuth(account, projectContext.auth);\n              authRecord = projectContext.auth;\n              try {\n                await accountManager.saveToDisk();\n              } catch (error) {\n                log.error(\"Failed to persist project context\", { error: String(error) });\n              }\n            }\n\n            const runThinkingWarmup = async (\n              prepared: ReturnType<typeof prepareAntigravityRequest>,\n              projectId: string,\n            ): Promise<void> => {\n              if (!prepared.needsSignedThinkingWarmup || !prepared.sessionId) {\n                return;\n              }\n\n              if (!trackWarmupAttempt(prepared.sessionId)) {\n                return;\n              }\n\n              const warmupBody = buildThinkingWarmupBody(\n                typeof prepared.init.body === \"string\" ? prepared.init.body : undefined,\n                Boolean(prepared.effectiveModel?.toLowerCase().includes(\"claude\") && prepared.effectiveModel?.toLowerCase().includes(\"thinking\")),\n              );\n              if (!warmupBody) {\n                return;\n              }\n\n              const warmupUrl = toWarmupStreamUrl(prepared.request);\n              const warmupHeaders = new Headers(prepared.init.headers ?? {});\n              warmupHeaders.set(\"accept\", \"text/event-stream\");\n\n              const warmupInit: RequestInit = {\n                ...prepared.init,\n                method: prepared.init.method ?? \"POST\",\n                headers: warmupHeaders,\n                body: warmupBody,\n              };\n\n              const warmupDebugContext = startAntigravityDebugRequest({\n                originalUrl: warmupUrl,\n                resolvedUrl: warmupUrl,\n                method: warmupInit.method,\n                headers: warmupHeaders,\n                body: warmupBody,\n                streaming: true,\n                projectId,\n              });\n\n              try {\n                pushDebug(\"thinking-warmup: start\");\n                const warmupResponse = await fetch(warmupUrl, warmupInit);\n                const transformed = await transformAntigravityResponse(\n                  warmupResponse,\n                  true,\n                  warmupDebugContext,\n                  prepared.requestedModel,\n                  projectId,\n                  warmupUrl,\n                  prepared.effectiveModel,\n                  prepared.sessionId,\n                );\n                await transformed.text();\n                markWarmupSuccess(prepared.sessionId);\n                pushDebug(\"thinking-warmup: done\");\n              } catch (error) {\n                clearWarmupAttempt(prepared.sessionId);\n                pushDebug(\n                  `thinking-warmup: failed ${error instanceof Error ? error.message : String(error)}`,\n                );\n              }\n            };\n\n            // Try endpoint fallbacks with single header style based on model suffix\n            let shouldSwitchAccount = false;\n            \n            // Determine header style from model suffix:\n            // - Models with antigravity- prefix -> use Antigravity quota\n            // - Gemini models without explicit prefix -> follow cli_first\n            // - Claude models -> always use Antigravity\n            let headerStyle = preferredHeaderStyle;\n            pushDebug(`headerStyle=${headerStyle} explicit=${explicitQuota}`);\n            if (account.fingerprint) {\n              pushDebug(`fingerprint: quotaUser=${account.fingerprint.quotaUser} deviceId=${account.fingerprint.deviceId.slice(0, 8)}...`);\n            }\n            \n            // Check if this header style is rate-limited for this account\n            if (accountManager.isRateLimitedForHeaderStyle(account, family, headerStyle, model)) {\n              // Antigravity-first fallback: exhaust antigravity across ALL accounts before gemini-cli\n              if (allowQuotaFallback && family === \"gemini\" && headerStyle === \"antigravity\") {\n                // Check if ANY other account has antigravity available\n                if (accountManager.hasOtherAccountWithAntigravityAvailable(account.index, family, model)) {\n                  // Switch to another account with antigravity (preserve antigravity priority)\n                  pushDebug(`antigravity rate-limited on account ${account.index}, but available on other accounts. Switching.`);\n                  shouldSwitchAccount = true;\n                } else {\n                  // All accounts exhausted antigravity - fall back to gemini-cli on this account\n                  const alternateStyle = accountManager.getAvailableHeaderStyle(account, family, model);\n                  const fallbackStyle = resolveQuotaFallbackHeaderStyle({\n                    family,\n                    headerStyle,\n                    alternateStyle,\n                  });\n                  if (fallbackStyle) {\n                    await showToast(\n                      `Antigravity quota exhausted on all accounts. Using Gemini CLI quota.`,\n                      \"warning\"\n                    );\n                    headerStyle = fallbackStyle;\n                    pushDebug(`all-accounts antigravity exhausted, quota fallback: ${headerStyle}`);\n                  } else {\n                    shouldSwitchAccount = true;\n                  }\n                }\n              } else if (allowQuotaFallback && family === \"gemini\") {\n                // gemini-cli rate-limited - try alternate style (antigravity) on same account\n                const alternateStyle = accountManager.getAvailableHeaderStyle(account, family, model);\n                const fallbackStyle = resolveQuotaFallbackHeaderStyle({\n                  family,\n                  headerStyle,\n                  alternateStyle,\n                });\n                if (fallbackStyle) {\n                  const quotaName = headerStyle === \"gemini-cli\" ? \"Gemini CLI\" : \"Antigravity\";\n                  const altQuotaName = fallbackStyle === \"gemini-cli\" ? \"Gemini CLI\" : \"Antigravity\";\n                  await showToast(\n                    `${quotaName} quota exhausted, using ${altQuotaName} quota`,\n                    \"warning\"\n                  );\n                  headerStyle = fallbackStyle;\n                  pushDebug(`quota fallback: ${headerStyle}`);\n                } else {\n                  shouldSwitchAccount = true;\n                }\n              } else {\n                shouldSwitchAccount = true;\n              }\n            }\n            \n            while (!shouldSwitchAccount) {\n            \n            // Flag to force thinking recovery on retry after API error\n            let forceThinkingRecovery = false;\n            \n            // Track if token was consumed (for hybrid strategy refund on error)\n            let tokenConsumed = false;\n            \n            // Track capacity retries per endpoint to prevent infinite loops\n            let capacityRetryCount = 0;\n            let lastEndpointIndex = -1;\n            \n            for (let i = 0; i < ANTIGRAVITY_ENDPOINT_FALLBACKS.length; i++) {\n              // Reset capacity retry counter when switching to a new endpoint\n              if (i !== lastEndpointIndex) {\n                capacityRetryCount = 0;\n                lastEndpointIndex = i;\n              }\n\n              const currentEndpoint = ANTIGRAVITY_ENDPOINT_FALLBACKS[i];\n\n              // Skip sandbox endpoints for Gemini CLI models - they only work with Antigravity quota\n              // Gemini CLI models must use production endpoint (cloudcode-pa.googleapis.com)\n              if (headerStyle === \"gemini-cli\" && currentEndpoint !== ANTIGRAVITY_ENDPOINT_PROD) {\n                pushDebug(`Skipping sandbox endpoint ${currentEndpoint} for gemini-cli headerStyle`);\n                continue;\n              }\n\n              try {\n                const prepared = prepareAntigravityRequest(\n                  input,\n                  init,\n                  accessToken,\n                  projectContext.effectiveProjectId,\n                  currentEndpoint,\n                  headerStyle,\n                  forceThinkingRecovery,\n                  {\n                    claudeToolHardening: config.claude_tool_hardening,\n                    claudePromptAutoCaching: config.claude_prompt_auto_caching,\n                    fingerprint: account.fingerprint,\n                  },\n                );\n\n                const originalUrl = toUrlString(input);\n                const resolvedUrl = toUrlString(prepared.request);\n                pushDebug(`endpoint=${currentEndpoint}`);\n                pushDebug(`resolved=${resolvedUrl}`);\n                const debugContext = startAntigravityDebugRequest({\n                  originalUrl,\n                  resolvedUrl,\n                  method: prepared.init.method,\n                  headers: prepared.init.headers,\n                  body: prepared.init.body,\n                  streaming: prepared.streaming,\n                  projectId: projectContext.effectiveProjectId,\n                });\n\n                const createFailureContext = (failureResponse: Response): FailureContext => ({\n                  response: failureResponse,\n                  streaming: prepared.streaming,\n                  debugContext,\n                  requestedModel: prepared.requestedModel,\n                  projectId: prepared.projectId,\n                  endpoint: prepared.endpoint,\n                  effectiveModel: prepared.effectiveModel,\n                  sessionId: prepared.sessionId,\n                  toolDebugMissing: prepared.toolDebugMissing,\n                  toolDebugSummary: prepared.toolDebugSummary,\n                  toolDebugPayload: prepared.toolDebugPayload,\n                });\n\n                await runThinkingWarmup(prepared, projectContext.effectiveProjectId);\n\n                if (config.request_jitter_max_ms > 0) {\n                  const jitterMs = Math.floor(Math.random() * config.request_jitter_max_ms);\n                  if (jitterMs > 0) {\n                    await sleep(jitterMs, abortSignal);\n                  }\n                }\n\n                // Consume token for hybrid strategy\n                // Refunded later if request fails (429 or network error)\n                if (config.account_selection_strategy === 'hybrid') {\n                  tokenConsumed = getTokenTracker().consume(account.index);\n                }\n\n                const response = await fetch(prepared.request, prepared.init);\n                pushDebug(`status=${response.status} ${response.statusText}`);\n\n\n\n\n                // Handle 429 rate limit (or Service Overloaded) with improved logic\n                if (response.status === 429 || response.status === 503 || response.status === 529) {\n                  // Refund token on rate limit\n                  if (tokenConsumed) {\n                    getTokenTracker().refund(account.index);\n                    tokenConsumed = false;\n                  }\n\n                  const defaultRetryMs = (config.default_retry_after_seconds ?? 60) * 1000;\n                  const maxBackoffMs = (config.max_backoff_seconds ?? 60) * 1000;\n                  const headerRetryMs = retryAfterMsFromResponse(response, defaultRetryMs);\n                  const bodyInfo = await extractRetryInfoFromBody(response);\n                  const serverRetryMs = bodyInfo.retryDelayMs ?? headerRetryMs;\n\n                  // [Enhanced Parsing] Pass status to handling logic\n                  const rateLimitReason = parseRateLimitReason(bodyInfo.reason, bodyInfo.message, response.status);\n\n                  // STRATEGY 1: CAPACITY / SERVER ERROR (Transient)\n                  // Goal: Wait and Retry SAME Account. DO NOT LOCK.\n                  // We handle this FIRST to avoid calling getRateLimitBackoff() and polluting the global rate limit state for transient errors.\n                  if (rateLimitReason === \"MODEL_CAPACITY_EXHAUSTED\" || rateLimitReason === \"SERVER_ERROR\") {\n                     // Exponential backoff with jitter for capacity errors: 1s → 2s → 4s → 8s (max)\n                     // Matches Antigravity-Manager's ExponentialBackoff(1s, 8s)\n                     const baseDelayMs = 1000;\n                     const maxDelayMs = 8000;\n                     const exponentialDelay = Math.min(baseDelayMs * Math.pow(2, capacityRetryCount), maxDelayMs);\n                     // Add ±10% jitter to prevent thundering herd\n                     const jitter = exponentialDelay * (0.9 + Math.random() * 0.2);\n                     const waitMs = Math.round(jitter);\n                     const waitSec = Math.round(waitMs / 1000);\n                     \n                     pushDebug(`Server busy (${rateLimitReason}) on account ${account.index}, exponential backoff ${waitMs}ms (attempt ${capacityRetryCount + 1})`);\n\n                     await showToast(\n                       `⏳ Server busy (${response.status}). Retrying in ${waitSec}s...`,\n                       \"warning\",\n                     );\n                     \n                     await sleep(waitMs, abortSignal);\n                     \n                     // CRITICAL FIX: Decrement i so that the loop 'continue' retries the SAME endpoint index\n                     // (i++ in the loop will bring it back to the current index)\n                     // But limit retries to prevent infinite loops (Greptile feedback)\n                     if (capacityRetryCount < 3) {\n                       capacityRetryCount++;\n                       i -= 1;\n                       continue; \n                      } else {\n                        pushDebug(`Max capacity retries (3) exhausted for endpoint ${currentEndpoint}, regenerating fingerprint...`);\n                        // Regenerate fingerprint to get fresh device identity before trying next endpoint\n                        const newFingerprint = accountManager.regenerateAccountFingerprint(account.index);\n                        if (newFingerprint) {\n                          pushDebug(`Fingerprint regenerated for account ${account.index}`);\n                        }\n                        continue;\n                      }\n                  }\n\n                  // STRATEGY 2: RATE LIMIT EXCEEDED (RPM) / QUOTA EXHAUSTED / UNKNOWN\n                  // Goal: Lock and Rotate (Standard Logic)\n                  \n                  // Only now do we call getRateLimitBackoff, which increments the global failure tracker\n                  const quotaKey = headerStyleToQuotaKey(headerStyle, family);\n                  const { attempt, delayMs, isDuplicate } = getRateLimitBackoff(account.index, quotaKey, serverRetryMs);\n                  \n                  // Calculate potential backoffs\n                  const smartBackoffMs = calculateBackoffMs(rateLimitReason, account.consecutiveFailures ?? 0, serverRetryMs);\n                  const effectiveDelayMs = Math.max(delayMs, smartBackoffMs);\n\n                  pushDebug(\n                    `429 idx=${account.index} email=${account.email ?? \"\"} family=${family} delayMs=${effectiveDelayMs} attempt=${attempt} reason=${rateLimitReason}`,\n                  );\n                  if (bodyInfo.message) {\n                    pushDebug(`429 message=${bodyInfo.message}`);\n                  }\n                  if (bodyInfo.quotaResetTime) {\n                    pushDebug(`429 quotaResetTime=${bodyInfo.quotaResetTime}`);\n                  }\n                  if (bodyInfo.reason) {\n                    pushDebug(`429 reason=${bodyInfo.reason}`);\n                  }\n\n                   logRateLimitEvent(\n                    account.index,\n                    account.email,\n                    family,\n                    response.status,\n                    effectiveDelayMs,\n                    bodyInfo,\n                  );\n\n                  await logResponseBody(debugContext, response, 429);\n\n                  getHealthTracker().recordRateLimit(account.index);\n\n                  const accountLabel = account.email || `Account ${account.index + 1}`;\n\n                  // Progressive retry for standard 429s: 1st 429 → 1s then switch (if enabled) or retry same\n                  if (attempt === 1 && rateLimitReason !== \"QUOTA_EXHAUSTED\") {\n                    await showToast(`Rate limited. Quick retry in 1s...`, \"warning\");\n                    await sleep(FIRST_RETRY_DELAY_MS, abortSignal);\n                    \n                    // CacheFirst mode: wait for same account if within threshold (preserves prompt cache)\n                    if (config.scheduling_mode === 'cache_first') {\n                      const maxCacheFirstWaitMs = config.max_cache_first_wait_seconds * 1000;\n                      // effectiveDelayMs is the backoff calculated for this account\n                      if (effectiveDelayMs <= maxCacheFirstWaitMs) {\n                        pushDebug(`cache_first: waiting ${effectiveDelayMs}ms for same account to recover`);\n                        await showToast(`⏳ Waiting ${Math.ceil(effectiveDelayMs / 1000)}s for same account (prompt cache preserved)...`, \"info\");\n                        accountManager.markRateLimitedWithReason(account, family, headerStyle, model, rateLimitReason, serverRetryMs);\n                        await sleep(effectiveDelayMs, abortSignal);\n                        // Retry same endpoint after wait\n                        i -= 1;\n                        continue;\n                      }\n                      // Wait time exceeds threshold, fall through to switch\n                      pushDebug(`cache_first: wait ${effectiveDelayMs}ms exceeds max ${maxCacheFirstWaitMs}ms, switching account`);\n                    }\n                    \n                    if (config.switch_on_first_rate_limit && accountCount > 1) {\n                      accountManager.markRateLimitedWithReason(account, family, headerStyle, model, rateLimitReason, serverRetryMs, config.failure_ttl_seconds * 1000);\n                      shouldSwitchAccount = true;\n                      break;\n                    }\n                    \n                    // Same endpoint retry for first RPM hit\n                    i -= 1; \n                    continue;\n                  }\n\n                  accountManager.markRateLimitedWithReason(account, family, headerStyle, model, rateLimitReason, serverRetryMs, config.failure_ttl_seconds * 1000);\n\n                  accountManager.requestSaveToDisk();\n\n                  // For Gemini, preserve preferred quota across accounts before fallback\n                  if (family === \"gemini\") {\n                    if (headerStyle === \"antigravity\") {\n                      // Check if any other account has Antigravity quota for this model\n                      if (hasOtherAccountWithAntigravity(account)) {\n                        pushDebug(`antigravity exhausted on account ${account.index}, but available on others. Switching account.`);\n                        await showToast(`Rate limited again. Switching account in 5s...`, \"warning\");\n                        await sleep(SWITCH_ACCOUNT_DELAY_MS, abortSignal);\n                        shouldSwitchAccount = true;\n                        break;\n                      }\n\n                      // All accounts exhausted for Antigravity on THIS model.\n                      // Before falling back to gemini-cli, check if it's the last option (automatic fallback)\n                      if (allowQuotaFallback) {\n                        const alternateStyle = accountManager.getAvailableHeaderStyle(account, family, model);\n                        const fallbackStyle = resolveQuotaFallbackHeaderStyle({\n                          family,\n                          headerStyle,\n                          alternateStyle,\n                        });\n                        if (fallbackStyle) {\n                          const safeModelName = model || \"this model\";\n                          await showToast(\n                            `Antigravity quota exhausted for ${safeModelName}. Switching to Gemini CLI quota...`,\n                            \"warning\"\n                          );\n                          headerStyle = fallbackStyle;\n                          pushDebug(`quota fallback: ${headerStyle}`);\n                          continue;\n                        }\n                      }\n                    } else if (headerStyle === \"gemini-cli\") {\n                      if (allowQuotaFallback) {\n                        const alternateStyle = accountManager.getAvailableHeaderStyle(account, family, model);\n                        const fallbackStyle = resolveQuotaFallbackHeaderStyle({\n                          family,\n                          headerStyle,\n                          alternateStyle,\n                        });\n                        if (fallbackStyle) {\n                          const safeModelName = model || \"this model\";\n                          await showToast(\n                            `Gemini CLI quota exhausted for ${safeModelName}. Switching to Antigravity quota...`,\n                            \"warning\"\n                          );\n                          headerStyle = fallbackStyle;\n                          pushDebug(`quota fallback: ${headerStyle}`);\n                          continue;\n                        }\n                      }\n                    }\n                  }\n\n                  const quotaName = headerStyle === \"antigravity\" ? \"Antigravity\" : \"Gemini CLI\";\n\n                  if (accountCount > 1) {\n                    const quotaMsg = bodyInfo.quotaResetTime \n                      ? ` (quota resets ${bodyInfo.quotaResetTime})`\n                      : ``;\n                    await showToast(`Rate limited again. Switching account in 5s...${quotaMsg}`, \"warning\");\n                    await sleep(SWITCH_ACCOUNT_DELAY_MS, abortSignal);\n                  } else {\n                    // Single account: exponential backoff (1s, 2s, 4s, 8s... max 60s)\n                    const expBackoffMs = Math.min(FIRST_RETRY_DELAY_MS * Math.pow(2, attempt - 1), 60000);\n                    const expBackoffFormatted = expBackoffMs >= 1000 ? `${Math.round(expBackoffMs / 1000)}s` : `${expBackoffMs}ms`;\n                    await showToast(`Rate limited. Retrying in ${expBackoffFormatted} (attempt ${attempt})...`, \"warning\");\n                    await sleep(expBackoffMs, abortSignal);\n                  }\n\n                  lastFailure = createFailureContext(response);\n                  shouldSwitchAccount = true;\n                  break;\n                }\n\n                // Success - reset rate limit backoff state for this quota\n                const quotaKey = headerStyleToQuotaKey(headerStyle, family);\n                resetRateLimitState(account.index, quotaKey);\n                resetAccountFailureState(account.index);\n\n                if (response.status === 403) {\n                  const errorBodyText = await response.clone().text().catch(() => \"\");\n                  const extracted = extractVerificationErrorDetails(errorBodyText);\n\n                  if (extracted.validationRequired) {\n                    const verificationReason = extracted.message ?? \"Google requires account verification.\";\n                    const cooldownMs = 10 * 60 * 1000;\n\n                    accountManager.markAccountVerificationRequired(account.index, verificationReason, extracted.verifyUrl);\n                    accountManager.markAccountCoolingDown(account, cooldownMs, \"validation-required\");\n                    accountManager.markRateLimited(account, cooldownMs, family, headerStyle, model);\n\n                    const label = account.email || `Account ${account.index + 1}`;\n                    if (accountManager.shouldShowAccountToast(account.index, 60000)) {\n                      await showToast(\n                        `⚠ ${label} needs verification. Run 'opencode auth login' and use Verify accounts.`,\n                        \"warning\",\n                      );\n                      accountManager.markToastShown(account.index);\n                    }\n\n                    pushDebug(`verification-required: disabled account ${account.index}`);\n                    getHealthTracker().recordFailure(account.index);\n\n                    lastFailure = createFailureContext(response);\n                    shouldSwitchAccount = true;\n                    break;\n                  }\n                }\n\n                const shouldRetryEndpoint = (\n                  response.status === 403 ||\n                  response.status === 404 ||\n                  response.status >= 500\n                );\n\n                if (shouldRetryEndpoint && i < ANTIGRAVITY_ENDPOINT_FALLBACKS.length - 1) {\n                  await logResponseBody(debugContext, response, response.status);\n                  lastFailure = createFailureContext(response);\n                  continue;\n                }\n\n                // Success or non-retryable error - return the response\n                if (response.ok) {\n                  account.consecutiveFailures = 0;\n                  getHealthTracker().recordSuccess(account.index);\n                  accountManager.markAccountUsed(account.index);\n                  \n                  void triggerAsyncQuotaRefreshForAccount(\n                    accountManager,\n                    account.index,\n                    client,\n                    providerId,\n                    config.quota_refresh_interval_minutes,\n                  );\n                }\n                logAntigravityDebugResponse(debugContext, response, {\n                  note: response.ok ? \"Success\" : `Error ${response.status}`,\n                });\n                if (response.ok && !prepared.streaming) {\n                  await logResponseBody(debugContext, response, response.status);\n                }\n                if (!response.ok) {\n                  await logResponseBody(debugContext, response, response.status);\n                  \n                  // Handle 400 \"Prompt too long\" with synthetic response to avoid session lock\n                  if (response.status === 400) {\n                    const cloned = response.clone();\n                    const bodyText = await cloned.text();\n                    if (bodyText.includes(\"Prompt is too long\") || bodyText.includes(\"prompt_too_long\")) {\n                      await showToast(\n                        \"Context too long - use /compact to reduce size\",\n                        \"warning\"\n                      );\n                      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`;\n                      return createSyntheticErrorResponse(errorMessage, prepared.requestedModel);\n                    }\n                  }\n                }\n                \n                // Empty response retry logic (ported from LLM-API-Key-Proxy)\n                // For non-streaming responses, check if the response body is empty\n                // and retry if so (up to config.empty_response_max_attempts times)\n                if (response.ok && !prepared.streaming) {\n                  const maxAttempts = config.empty_response_max_attempts ?? 4;\n                  const retryDelayMs = config.empty_response_retry_delay_ms ?? 2000;\n                  \n                  // Clone to check body without consuming original\n                  const clonedForCheck = response.clone();\n                  const bodyText = await clonedForCheck.text();\n                  \n                  if (isEmptyResponseBody(bodyText)) {\n                    // Track empty response attempts per request\n                    const emptyAttemptKey = `${prepared.sessionId ?? \"none\"}:${prepared.effectiveModel ?? \"unknown\"}`;\n                    const currentAttempts = (emptyResponseAttempts.get(emptyAttemptKey) ?? 0) + 1;\n                    emptyResponseAttempts.set(emptyAttemptKey, currentAttempts);\n                    \n                    pushDebug(`empty-response: attempt ${currentAttempts}/${maxAttempts}`);\n                    \n                    if (currentAttempts < maxAttempts) {\n                      await showToast(\n                        `Empty response received. Retrying (${currentAttempts}/${maxAttempts})...`,\n                        \"warning\"\n                      );\n                      await sleep(retryDelayMs, abortSignal);\n                      continue; // Retry the endpoint loop\n                    }\n                    \n                    // Clean up and throw after max attempts\n                    emptyResponseAttempts.delete(emptyAttemptKey);\n                    throw new EmptyResponseError(\n                      \"antigravity\",\n                      prepared.effectiveModel ?? \"unknown\",\n                      currentAttempts,\n                    );\n                  }\n                  \n                  // Clean up successful attempt tracking\n                  const emptyAttemptKeyClean = `${prepared.sessionId ?? \"none\"}:${prepared.effectiveModel ?? \"unknown\"}`;\n                  emptyResponseAttempts.delete(emptyAttemptKeyClean);\n                }\n                \n                const transformedResponse = await transformAntigravityResponse(\n                  response,\n                  prepared.streaming,\n                  debugContext,\n                  prepared.requestedModel,\n                  prepared.projectId,\n                  prepared.endpoint,\n                  prepared.effectiveModel,\n                  prepared.sessionId,\n                  prepared.toolDebugMissing,\n                  prepared.toolDebugSummary,\n                  prepared.toolDebugPayload,\n                  debugLines,\n                );\n\n                // Check for context errors and show appropriate toast\n                const contextError = transformedResponse.headers.get(\"x-antigravity-context-error\");\n                if (contextError) {\n                  if (contextError === \"prompt_too_long\") {\n                    await showToast(\n                      \"Context too long - use /compact to reduce size, or trim your request\",\n                      \"warning\"\n                    );\n                  } else if (contextError === \"tool_pairing\") {\n                    await showToast(\n                      \"Tool call/result mismatch - use /compact to fix, or /undo last message\",\n                      \"warning\"\n                    );\n                  }\n                }\n\n                return transformedResponse;\n              } catch (error) {\n                // Refund token on network/API error (only if consumed)\n                if (tokenConsumed) {\n                  getTokenTracker().refund(account.index);\n                  tokenConsumed = false;\n                }\n\n                // Handle recoverable thinking errors - retry with forced recovery\n                if (error instanceof Error && error.message === \"THINKING_RECOVERY_NEEDED\") {\n                  // Only retry once with forced recovery to avoid infinite loops\n                  if (!forceThinkingRecovery) {\n                    pushDebug(\"thinking-recovery: API error detected, retrying with forced recovery\");\n                    forceThinkingRecovery = true;\n                    i = -1; // Will become 0 after loop increment, restart endpoint loop\n                    continue;\n                  }\n                  \n                  // Already tried with forced recovery, give up and return error\n                  const recoveryError = error as any;\n                  const originalError = recoveryError.originalError || { error: { message: \"Thinking recovery triggered\" } };\n                  \n                  const recoveryMessage = `${originalError.error?.message || \"Session recovery failed\"}\\n\\n[RECOVERY] Thinking block corruption could not be resolved. Try starting a new session.`;\n                  \n                  return new Response(JSON.stringify({\n                    type: \"error\",\n                    error: {\n                      type: \"unrecoverable_error\",\n                      message: recoveryMessage\n                    }\n                  }), {\n                    status: 400,\n                    headers: { \"Content-Type\": \"application/json\" }\n                  });\n                }\n\n                if (i < ANTIGRAVITY_ENDPOINT_FALLBACKS.length - 1) {\n                  lastError = error instanceof Error ? error : new Error(String(error));\n                  continue;\n                }\n\n                // All endpoints failed for this account - track failure and try next account\n                const { failures, shouldCooldown, cooldownMs } = trackAccountFailure(account.index);\n                lastError = error instanceof Error ? error : new Error(String(error));\n                if (shouldCooldown) {\n                  accountManager.markAccountCoolingDown(account, cooldownMs, \"network-error\");\n                  accountManager.markRateLimited(account, cooldownMs, family, headerStyle, model);\n                  pushDebug(`endpoint-error: cooldown ${cooldownMs}ms after ${failures} failures`);\n                }\n                shouldSwitchAccount = true;\n                break;\n              }\n            }\n            } // end headerStyleLoop\n            \n            if (shouldSwitchAccount) {\n              // Avoid tight retry loops when there's only one account.\n              if (accountCount <= 1) {\n                if (lastFailure) {\n                  return transformAntigravityResponse(\n                    lastFailure.response,\n                    lastFailure.streaming,\n                    lastFailure.debugContext,\n                    lastFailure.requestedModel,\n                    lastFailure.projectId,\n                    lastFailure.endpoint,\n                    lastFailure.effectiveModel,\n                    lastFailure.sessionId,\n                    lastFailure.toolDebugMissing,\n                    lastFailure.toolDebugSummary,\n                    lastFailure.toolDebugPayload,\n                    debugLines,\n                  );\n                }\n\n                throw lastError || new Error(\"All Antigravity endpoints failed\");\n              }\n\n              continue;\n            }\n\n            // If we get here without returning, something went wrong\n            if (lastFailure) {\n              return transformAntigravityResponse(\n                lastFailure.response,\n                lastFailure.streaming,\n                lastFailure.debugContext,\n                lastFailure.requestedModel,\n                lastFailure.projectId,\n                lastFailure.endpoint,\n                lastFailure.effectiveModel,\n                lastFailure.sessionId,\n                lastFailure.toolDebugMissing,\n                lastFailure.toolDebugSummary,\n                lastFailure.toolDebugPayload,\n                debugLines,\n              );\n            }\n\n            throw lastError || new Error(\"All Antigravity accounts failed\");\n          }\n        },\n      };\n    },\n    methods: [\n      {\n        label: \"OAuth with Google (Antigravity)\",\n        type: \"oauth\",\n        authorize: async (inputs?: Record<string, string>) => {\n          const isHeadless = !!(\n            process.env.SSH_CONNECTION ||\n            process.env.SSH_CLIENT ||\n            process.env.SSH_TTY ||\n            process.env.OPENCODE_HEADLESS\n          );\n\n          // CLI flow (`opencode auth login`) passes an inputs object.\n          if (inputs) {\n            const accounts: Array<Extract<AntigravityTokenExchangeResult, { type: \"success\" }>> = [];\n            const noBrowser = inputs.noBrowser === \"true\" || inputs[\"no-browser\"] === \"true\";\n            const useManualMode = noBrowser || shouldSkipLocalServer();\n\n            // Check for existing accounts and prompt user for login mode\n            let startFresh = true;\n            let refreshAccountIndex: number | undefined;\n            const existingStorage = await loadAccounts();\n            if (existingStorage && existingStorage.accounts.length > 0) {\n              let menuResult;\n              while (true) {\n                const now = Date.now();\n                const existingAccounts = existingStorage.accounts.map((acc, idx) => {\n                  let status: 'active' | 'rate-limited' | 'expired' | 'verification-required' | 'unknown' = 'unknown';\n\n                  if (acc.verificationRequired) {\n                    status = 'verification-required';\n                  } else {\n                    const rateLimits = acc.rateLimitResetTimes;\n                    if (rateLimits) {\n                      const isRateLimited = Object.values(rateLimits).some(\n                        (resetTime) => typeof resetTime === 'number' && resetTime > now\n                      );\n                      if (isRateLimited) {\n                        status = 'rate-limited';\n                      } else {\n                        status = 'active';\n                      }\n                    } else {\n                      status = 'active';\n                    }\n\n                    if (acc.coolingDownUntil && acc.coolingDownUntil > now) {\n                      status = 'rate-limited';\n                    }\n                  }\n\n                  return {\n                    email: acc.email,\n                    index: idx,\n                    addedAt: acc.addedAt,\n                    lastUsed: acc.lastUsed,\n                    status,\n                    isCurrentAccount: idx === (existingStorage.activeIndex ?? 0),\n                    enabled: acc.enabled !== false,\n                  };\n                });\n                \n                menuResult = await promptLoginMode(existingAccounts);\n\n                if (menuResult.mode === \"check\") {\n                  console.log(\"\\n📊 Checking quotas for all accounts...\\n\");\n                  const results = await checkAccountsQuota(existingStorage.accounts, client, providerId);\n                  let storageUpdated = false;\n                  \n                  for (const res of results) {\n                    const label = res.email || `Account ${res.index + 1}`;\n                    const disabledStr = res.disabled ? \" (disabled)\" : \"\";\n                    console.log(`━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━`);\n                    console.log(`  ${label}${disabledStr}`);\n                    console.log(`━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━`);\n                    \n                    if (res.status === \"error\") {\n                      console.log(`  ❌ Error: ${res.error}\\n`);\n                      continue;\n                    }\n\n                    // ANSI color codes\n                    const colors = {\n                      red: '\\x1b[31m',\n                      orange: '\\x1b[33m',  // Yellow/orange\n                      green: '\\x1b[32m',\n                      reset: '\\x1b[0m',\n                    };\n\n                    // Get color based on remaining percentage\n                    const getColor = (remaining?: number): string => {\n                      if (typeof remaining !== 'number') return colors.reset;\n                      if (remaining < 0.2) return colors.red;\n                      if (remaining < 0.6) return colors.orange;\n                      return colors.green;\n                    };\n\n                    // Helper to create colored progress bar\n                    const createProgressBar = (remaining?: number, width: number = 20): string => {\n                      if (typeof remaining !== 'number') return '░'.repeat(width) + ' ???';\n                      const filled = Math.round(remaining * width);\n                      const empty = width - filled;\n                      const color = getColor(remaining);\n                      const bar = `${color}${'█'.repeat(filled)}${colors.reset}${'░'.repeat(empty)}`;\n                      const pct = `${color}${Math.round(remaining * 100)}%${colors.reset}`.padStart(4 + color.length + colors.reset.length);\n                      return `${bar} ${pct}`;\n                    };\n\n                    // Helper to format reset time with days support\n                    const formatReset = (resetTime?: string): string => {\n                      if (!resetTime) return '';\n                      const ms = Date.parse(resetTime) - Date.now();\n                      if (ms <= 0) return ' (resetting...)';\n                      \n                      const hours = ms / (1000 * 60 * 60);\n                      if (hours >= 24) {\n                        const days = Math.floor(hours / 24);\n                        const remainingHours = Math.floor(hours % 24);\n                        if (remainingHours > 0) {\n                          return ` (resets in ${days}d ${remainingHours}h)`;\n                        }\n                        return ` (resets in ${days}d)`;\n                      }\n                      return ` (resets in ${formatWaitTime(ms)})`;\n                    };\n\n                    // Display Gemini CLI Quota first (as requested - swap order)\n                    const hasGeminiCli = res.geminiCliQuota && res.geminiCliQuota.models.length > 0;\n                    console.log(`\\n  ┌─ Gemini CLI Quota`);\n                    if (!hasGeminiCli) {\n                      const errorMsg = res.geminiCliQuota?.error || \"No Gemini CLI quota available\";\n                      console.log(`  │  └─ ${errorMsg}`);\n                    } else {\n                      const models = res.geminiCliQuota!.models;\n                      models.forEach((model, idx) => {\n                        const isLast = idx === models.length - 1;\n                        const connector = isLast ? \"└─\" : \"├─\";\n                        const bar = createProgressBar(model.remainingFraction);\n                        const reset = formatReset(model.resetTime);\n                        const modelName = model.modelId.padEnd(29);\n                        console.log(`  │  ${connector} ${modelName} ${bar}${reset}`);\n                      });\n                    }\n\n                    // Display Antigravity Quota second\n                    const hasAntigravity = res.quota && Object.keys(res.quota.groups).length > 0;\n                    console.log(`  │`);\n                    console.log(`  └─ Antigravity Quota`);\n                    if (!hasAntigravity) {\n                      const errorMsg = res.quota?.error || \"No quota information available\";\n                      console.log(`     └─ ${errorMsg}`);\n                    } else {\n                      const groups = res.quota!.groups;\n                      const groupEntries = [\n                        { name: \"Claude\", data: groups.claude },\n                        { name: \"Gemini 3 Pro\", data: groups[\"gemini-pro\"] },\n                        { name: \"Gemini 3 Flash\", data: groups[\"gemini-flash\"] },\n                      ].filter(g => g.data);\n                      \n                      groupEntries.forEach((g, idx) => {\n                        const isLast = idx === groupEntries.length - 1;\n                        const connector = isLast ? \"└─\" : \"├─\";\n                        const bar = createProgressBar(g.data!.remainingFraction);\n                        const reset = formatReset(g.data!.resetTime);\n                        const modelName = g.name.padEnd(29);\n                        console.log(`     ${connector} ${modelName} ${bar}${reset}`);\n                      });\n                    }\n                    console.log(\"\");\n\n                    // Cache quota data for soft quota protection\n                    if (res.quota?.groups) {\n                      const acc = existingStorage.accounts[res.index];\n                      if (acc) {\n                        acc.cachedQuota = res.quota.groups;\n                        acc.cachedQuotaUpdatedAt = Date.now();\n                        storageUpdated = true;\n                      }\n                    }\n\n                    if (res.updatedAccount) {\n                      existingStorage.accounts[res.index] = {\n                        ...res.updatedAccount,\n                        cachedQuota: res.quota?.groups,\n                        cachedQuotaUpdatedAt: Date.now(),\n                      };\n                      storageUpdated = true;\n                    }\n                  }\n                  if (storageUpdated) {\n                    await saveAccounts(existingStorage);\n                  }\n                  console.log(\"\");\n                  continue;\n                }\n\n                if (menuResult.mode === \"manage\") {\n                  if (menuResult.toggleAccountIndex !== undefined) {\n                    const acc = existingStorage.accounts[menuResult.toggleAccountIndex];\n                    if (acc) {\n                      acc.enabled = acc.enabled === false;\n                      await saveAccounts(existingStorage);\n                      activeAccountManager?.setAccountEnabled(menuResult.toggleAccountIndex, acc.enabled);\n                      console.log(`\\nAccount ${acc.email || menuResult.toggleAccountIndex + 1} ${acc.enabled ? 'enabled' : 'disabled'}.\\n`);\n                    }\n                  }\n                  continue;\n                }\n\n                if (menuResult.mode === \"verify\" || menuResult.mode === \"verify-all\") {\n                  const verifyAll = menuResult.mode === \"verify-all\" || menuResult.verifyAll === true;\n\n                  if (verifyAll) {\n                    if (existingStorage.accounts.length === 0) {\n                      console.log(\"\\nNo accounts available to verify.\\n\");\n                      continue;\n                    }\n\n                    console.log(`\\nChecking verification status for ${existingStorage.accounts.length} account(s)...\\n`);\n\n                    let okCount = 0;\n                    let blockedCount = 0;\n                    let errorCount = 0;\n                    let storageUpdated = false;\n\n                    const blockedResults: Array<{ label: string; message: string; verifyUrl?: string }> = [];\n\n                    for (let i = 0; i < existingStorage.accounts.length; i++) {\n                      const account = existingStorage.accounts[i];\n                      if (!account) continue;\n\n                      const label = account.email || `Account ${i + 1}`;\n                      process.stdout.write(`- [${i + 1}/${existingStorage.accounts.length}] ${label} ... `);\n\n                      const verification = await verifyAccountAccess(account, client, providerId);\n                      if (verification.status === \"ok\") {\n                        const { changed, wasVerificationRequired } = clearStoredAccountVerificationRequired(account, true);\n                        if (changed) {\n                          storageUpdated = true;\n                        }\n                        activeAccountManager?.clearAccountVerificationRequired(i, wasVerificationRequired);\n                        okCount += 1;\n                        console.log(\"ok\");\n                        continue;\n                      }\n\n                      if (verification.status === \"blocked\") {\n                        const changed = markStoredAccountVerificationRequired(\n                          account,\n                          verification.message,\n                          verification.verifyUrl,\n                        );\n                        if (changed) {\n                          storageUpdated = true;\n                        }\n                        activeAccountManager?.markAccountVerificationRequired(i, verification.message, verification.verifyUrl);\n\n                        blockedCount += 1;\n                        console.log(\"needs verification\");\n                        const verifyUrl = verification.verifyUrl ?? account.verificationUrl;\n                        blockedResults.push({\n                          label,\n                          message: verification.message,\n                          verifyUrl,\n                        });\n                        continue;\n                      }\n\n                      errorCount += 1;\n                      console.log(`error (${verification.message})`);\n                    }\n\n                    if (storageUpdated) {\n                      await saveAccounts(existingStorage);\n                    }\n\n                    console.log(`\\nVerification summary: ${okCount} ready, ${blockedCount} need verification, ${errorCount} errors.`);\n\n                    if (blockedResults.length > 0) {\n                      console.log(\"\\nAccounts needing verification:\");\n                      for (const result of blockedResults) {\n                        console.log(`\\n- ${result.label}`);\n                        console.log(`  ${result.message}`);\n                        if (result.verifyUrl) {\n                          console.log(`  URL: ${result.verifyUrl}`);\n                        } else {\n                          console.log(\"  URL: not provided by API response\");\n                        }\n                      }\n                      console.log(\"\");\n                    } else {\n                      console.log(\"\");\n                    }\n\n                    continue;\n                  }\n\n                  let verifyAccountIndex = menuResult.verifyAccountIndex;\n                  if (verifyAccountIndex === undefined) {\n                    verifyAccountIndex = await promptAccountIndexForVerification(existingAccounts);\n                  }\n\n                  if (verifyAccountIndex === undefined) {\n                    console.log(\"\\nVerification cancelled.\\n\");\n                    continue;\n                  }\n\n                  const account = existingStorage.accounts[verifyAccountIndex];\n                  if (!account) {\n                    console.log(`\\nAccount ${verifyAccountIndex + 1} not found.\\n`);\n                    continue;\n                  }\n\n                  const label = account.email || `Account ${verifyAccountIndex + 1}`;\n                  console.log(`\\nChecking verification status for ${label}...\\n`);\n\n                  const verification = await verifyAccountAccess(account, client, providerId);\n\n                  if (verification.status === \"ok\") {\n                    const { changed, wasVerificationRequired } = clearStoredAccountVerificationRequired(account, true);\n                    if (changed) {\n                      await saveAccounts(existingStorage);\n                    }\n                    activeAccountManager?.clearAccountVerificationRequired(verifyAccountIndex, wasVerificationRequired);\n\n                    if (wasVerificationRequired) {\n                      console.log(`✓ ${label} is ready for requests and has been re-enabled.\\n`);\n                    } else {\n                      console.log(`✓ ${label} is ready for requests.\\n`);\n                    }\n                    continue;\n                  }\n\n                  if (verification.status === \"blocked\") {\n                    const changed = markStoredAccountVerificationRequired(\n                      account,\n                      verification.message,\n                      verification.verifyUrl,\n                    );\n                    if (changed) {\n                      await saveAccounts(existingStorage);\n                    }\n                    activeAccountManager?.markAccountVerificationRequired(\n                      verifyAccountIndex,\n                      verification.message,\n                      verification.verifyUrl,\n                    );\n\n                    const verifyUrl = verification.verifyUrl ?? account.verificationUrl;\n                    console.log(`⚠ ${label} needs Google verification before it can be used.`);\n                    if (verification.message) {\n                      console.log(verification.message);\n                    }\n                    console.log(`${label} has been disabled until verification is completed.`);\n                    if (verifyUrl) {\n                      console.log(`\\nVerification URL:\\n${verifyUrl}\\n`);\n                      if (await promptOpenVerificationUrl()) {\n                        const opened = await openBrowser(verifyUrl);\n                        if (opened) {\n                          console.log(\"Opened verification URL in your browser.\\n\");\n                        } else {\n                          console.log(\"Could not open browser automatically. Please open the URL manually.\\n\");\n                        }\n                      }\n                    } else {\n                      console.log(\"No verification URL was returned. Try re-authenticating this account.\\n\");\n                    }\n                    continue;\n                  }\n\n                  console.log(`✗ ${label}: ${verification.message}\\n`);\n                  continue;\n                }\n\n                break;\n              }\n              \n              if (menuResult.mode === \"cancel\") {\n                return {\n                  url: \"\",\n                  instructions: \"Authentication cancelled\",\n                  method: \"auto\",\n                  callback: async () => ({ type: \"failed\", error: \"Authentication cancelled\" }),\n                };\n              }\n              \n              if (menuResult.deleteAccountIndex !== undefined) {\n                const updatedAccounts = existingStorage.accounts.filter(\n                  (_, idx) => idx !== menuResult.deleteAccountIndex\n                );\n                // Use saveAccountsReplace to bypass merge (otherwise deleted account gets merged back)\n                await saveAccountsReplace({\n                  version: 4,\n                  accounts: updatedAccounts,\n                  activeIndex: 0,\n                  activeIndexByFamily: { claude: 0, gemini: 0 },\n                });\n                // Sync in-memory state so deleted account stops being used immediately\n                activeAccountManager?.removeAccountByIndex(menuResult.deleteAccountIndex);\n                console.log(\"\\nAccount deleted.\\n\");\n\n                if (updatedAccounts.length > 0) {\n                  const fallbackAccount = updatedAccounts[0];\n                  if (fallbackAccount?.refreshToken) {\n                    const fallbackResult = buildAuthSuccessFromStoredAccount(fallbackAccount);\n                    try {\n                      await client.auth.set({\n                        path: { id: providerId },\n                        body: { type: \"oauth\", refresh: fallbackResult.refresh, access: \"\", expires: 0 },\n                      });\n                    } catch (storeError) {\n                      log.error(\"Failed to update stored Antigravity OAuth credentials\", { error: String(storeError) });\n                    }\n\n                    const label = fallbackAccount.email || `Account ${1}`;\n                    return {\n                      url: \"\",\n                      instructions: `Account deleted. Using ${label} for future requests.`,\n                      method: \"auto\",\n                      callback: async () => fallbackResult,\n                    };\n                  }\n                }\n\n                try {\n                  await client.auth.set({\n                    path: { id: providerId },\n                    body: { type: \"oauth\", refresh: \"\", access: \"\", expires: 0 },\n                  });\n                } catch (storeError) {\n                  log.error(\"Failed to clear stored Antigravity OAuth credentials\", { error: String(storeError) });\n                }\n\n                return {\n                  url: \"\",\n                  instructions: \"All accounts deleted. Run `opencode auth login` to reauthenticate.\",\n                  method: \"auto\",\n                  callback: async () => ({\n                    type: \"failed\",\n                    error: \"All accounts deleted. Reauthentication required.\",\n                  }),\n                };\n              }\n\n              if (menuResult.refreshAccountIndex !== undefined) {\n                refreshAccountIndex = menuResult.refreshAccountIndex;\n                const refreshEmail = existingStorage.accounts[refreshAccountIndex]?.email;\n                console.log(`\\nRe-authenticating ${refreshEmail || 'account'}...\\n`);\n                startFresh = false;\n              }\n              \n              if (menuResult.deleteAll) {\n                await clearAccounts();\n                console.log(\"\\nAll accounts deleted.\\n\");\n                startFresh = true;\n                try {\n                  await client.auth.set({\n                    path: { id: providerId },\n                    body: { type: \"oauth\", refresh: \"\", access: \"\", expires: 0 },\n                  });\n                } catch (storeError) {\n                  log.error(\"Failed to clear stored Antigravity OAuth credentials\", { error: String(storeError) });\n                }\n              } else {\n                startFresh = menuResult.mode === \"fresh\";\n              }\n              \n              if (startFresh && !menuResult.deleteAll) {\n                console.log(\"\\nStarting fresh - existing accounts will be replaced.\\n\");\n              } else if (!startFresh) {\n                console.log(\"\\nAdding to existing accounts.\\n\");\n              }\n            }\n\n            while (accounts.length < MAX_OAUTH_ACCOUNTS) {\n              console.log(`\\n=== Antigravity OAuth (Account ${accounts.length + 1}) ===`);\n\n              const projectId = await promptProjectId();\n\n              const result = await (async (): Promise<AntigravityTokenExchangeResult> => {\n                const authorization = await authorizeAntigravity(projectId);\n                const fallbackState = getStateFromAuthorizationUrl(authorization.url);\n\n                console.log(\"\\nOAuth URL:\\n\" + authorization.url + \"\\n\");\n\n                if (useManualMode) {\n                  const browserOpened = await openBrowser(authorization.url);\n                  if (!browserOpened) {\n                    console.log(\"Could not open browser automatically.\");\n                    console.log(\"Please open the URL above manually in your local browser.\\n\");\n                  }\n                  return promptManualOAuthInput(fallbackState);\n                }\n\n                let listener: OAuthListener | null = null;\n                if (!isHeadless) {\n                  try {\n                    listener = await startOAuthListener();\n                  } catch {\n                    listener = null;\n                  }\n                }\n\n                if (!isHeadless) {\n                  await openBrowser(authorization.url);\n                }\n\n                if (listener) {\n                  try {\n                    const SOFT_TIMEOUT_MS = 30000;\n                    const callbackPromise = listener.waitForCallback();\n                    const timeoutPromise = new Promise<never>((_, reject) =>\n                      setTimeout(() => reject(new Error(\"SOFT_TIMEOUT\")), SOFT_TIMEOUT_MS)\n                    );\n\n                    let callbackUrl: URL;\n                    try {\n                      callbackUrl = await Promise.race([callbackPromise, timeoutPromise]);\n                    } catch (err) {\n                      if (err instanceof Error && err.message === \"SOFT_TIMEOUT\") {\n                        console.log(\"\\n⏳ Automatic callback not received after 30 seconds.\");\n                        console.log(\"You can paste the redirect URL manually.\\n\");\n                        console.log(\"OAuth URL (in case you need it again):\");\n                        console.log(authorization.url + \"\\n\");\n                        \n                        try {\n                          await listener.close();\n                        } catch {}\n                        \n                        return promptManualOAuthInput(fallbackState);\n                      }\n                      throw err;\n                    }\n\n                    const params = extractOAuthCallbackParams(callbackUrl);\n                    if (!params) {\n                      return { type: \"failed\", error: \"Missing code or state in callback URL\" };\n                    }\n\n                    return exchangeAntigravity(params.code, params.state);\n                  } catch (error) {\n                    if (error instanceof Error && error.message !== \"SOFT_TIMEOUT\") {\n                      return {\n                        type: \"failed\",\n                        error: error.message,\n                      };\n                    }\n                    return {\n                      type: \"failed\",\n                      error: error instanceof Error ? error.message : \"Unknown error\",\n                    };\n                  } finally {\n                    try {\n                      await listener.close();\n                    } catch {}\n                  }\n                }\n\n                return promptManualOAuthInput(fallbackState);\n              })();\n\n              if (result.type === \"failed\") {\n                if (accounts.length === 0) {\n                  return {\n                    url: \"\",\n                    instructions: `Authentication failed: ${result.error}`,\n                    method: \"auto\",\n                    callback: async () => result,\n                  };\n                }\n\n                console.warn(\n                  `[opencode-antigravity-auth] Skipping failed account ${accounts.length + 1}: ${result.error}`,\n                );\n                break;\n              }\n\n              accounts.push(result);\n\n              try {\n                await client.tui.showToast({\n                  body: {\n                    message: `Account ${accounts.length} authenticated${result.email ? ` (${result.email})` : \"\"}`,\n                    variant: \"success\",\n                  },\n                });\n              } catch {\n              }\n\n              try {\n                if (refreshAccountIndex !== undefined) {\n                  const currentStorage = await loadAccounts();\n                  if (currentStorage) {\n                    const updatedAccounts = [...currentStorage.accounts];\n                    const parts = parseRefreshParts(result.refresh);\n                    if (parts.refreshToken) {\n                      updatedAccounts[refreshAccountIndex] = {\n                        email: result.email ?? updatedAccounts[refreshAccountIndex]?.email,\n                        refreshToken: parts.refreshToken,\n                        projectId: parts.projectId ?? updatedAccounts[refreshAccountIndex]?.projectId,\n                        managedProjectId: parts.managedProjectId ?? updatedAccounts[refreshAccountIndex]?.managedProjectId,\n                        addedAt: updatedAccounts[refreshAccountIndex]?.addedAt ?? Date.now(),\n                        lastUsed: Date.now(),\n                      };\n                      await saveAccounts({\n                        version: 4,\n                        accounts: updatedAccounts,\n                        activeIndex: currentStorage.activeIndex,\n                        activeIndexByFamily: currentStorage.activeIndexByFamily,\n                      });\n                    }\n                  }\n                } else {\n                  const isFirstAccount = accounts.length === 1;\n                  await persistAccountPool([result], isFirstAccount && startFresh);\n                }\n              } catch {\n              }\n\n              if (refreshAccountIndex !== undefined) {\n                break;\n              }\n\n              if (accounts.length >= MAX_OAUTH_ACCOUNTS) {\n                break;\n              }\n\n              // Get the actual deduplicated account count from storage for the prompt\n              let currentAccountCount = accounts.length;\n              try {\n                const currentStorage = await loadAccounts();\n                if (currentStorage) {\n                  currentAccountCount = currentStorage.accounts.length;\n                }\n              } catch {\n                // Fall back to accounts.length if we can't read storage\n              }\n\n              const addAnother = await promptAddAnotherAccount(currentAccountCount);\n              if (!addAnother) {\n                break;\n              }\n            }\n\n            const primary = accounts[0];\n            if (!primary) {\n              return {\n                url: \"\",\n                instructions: \"Authentication cancelled\",\n                method: \"auto\",\n                callback: async () => ({ type: \"failed\", error: \"Authentication cancelled\" }),\n              };\n            }\n\n            let actualAccountCount = accounts.length;\n            try {\n              const finalStorage = await loadAccounts();\n              if (finalStorage) {\n                actualAccountCount = finalStorage.accounts.length;\n              }\n            } catch {\n            }\n\n            const successMessage = refreshAccountIndex !== undefined\n              ? `Token refreshed successfully.`\n              : `Multi-account setup complete (${actualAccountCount} account(s)).`;\n\n            return {\n              url: \"\",\n              instructions: successMessage,\n              method: \"auto\",\n              callback: async (): Promise<AntigravityTokenExchangeResult> => primary,\n            };\n          }\n\n          // TUI flow (`/connect`) does not support per-account prompts.\n          // Default to adding new accounts (non-destructive).\n          // Users can run `opencode auth logout` first if they want a fresh start.\n          const projectId = \"\";\n\n          // Check existing accounts count for toast message\n          const existingStorage = await loadAccounts();\n          const existingCount = existingStorage?.accounts.length ?? 0;\n\n          const useManualFlow = isHeadless || shouldSkipLocalServer();\n\n          let listener: OAuthListener | null = null;\n          if (!useManualFlow) {\n            try {\n              listener = await startOAuthListener();\n            } catch {\n              listener = null;\n            }\n          }\n\n          const authorization = await authorizeAntigravity(projectId);\n          const fallbackState = getStateFromAuthorizationUrl(authorization.url);\n\n          if (!useManualFlow) {\n            const browserOpened = await openBrowser(authorization.url);\n            if (!browserOpened) {\n              listener?.close().catch(() => {});\n              listener = null;\n            }\n          }\n\n          if (listener) {\n            return {\n              url: authorization.url,\n              instructions:\n                \"Complete sign-in in your browser. We'll automatically detect the redirect back to localhost.\",\n              method: \"auto\",\n              callback: async (): Promise<AntigravityTokenExchangeResult> => {\n                const CALLBACK_TIMEOUT_MS = 30000;\n                try {\n                  const callbackPromise = listener.waitForCallback();\n                  const timeoutPromise = new Promise<never>((_, reject) =>\n                    setTimeout(() => reject(new Error(\"CALLBACK_TIMEOUT\")), CALLBACK_TIMEOUT_MS),\n                  );\n\n                  let callbackUrl: URL;\n                  try {\n                    callbackUrl = await Promise.race([callbackPromise, timeoutPromise]);\n                  } catch (err) {\n                    if (err instanceof Error && err.message === \"CALLBACK_TIMEOUT\") {\n                      return {\n                        type: \"failed\",\n                        error: \"Callback timeout - please use CLI with --no-browser flag for manual input\",\n                      };\n                    }\n                    throw err;\n                  }\n\n                  const params = extractOAuthCallbackParams(callbackUrl);\n                  if (!params) {\n                    return { type: \"failed\", error: \"Missing code or state in callback URL\" };\n                  }\n\n                  const result = await exchangeAntigravity(params.code, params.state);\n                  if (result.type === \"success\") {\n                    try {\n                      await persistAccountPool([result], false);\n                    } catch {\n                    }\n\n                    const newTotal = existingCount + 1;\n                    const toastMessage = existingCount > 0\n                      ? `Added account${result.email ? ` (${result.email})` : \"\"} - ${newTotal} total`\n                      : `Authenticated${result.email ? ` (${result.email})` : \"\"}`;\n\n                    try {\n                      await client.tui.showToast({\n                        body: {\n                          message: toastMessage,\n                          variant: \"success\",\n                        },\n                      });\n                    } catch {\n                    }\n                  }\n\n                  return result;\n                } catch (error) {\n                  return {\n                    type: \"failed\",\n                    error: error instanceof Error ? error.message : \"Unknown error\",\n                  };\n                } finally {\n                  try {\n                    await listener.close();\n                  } catch {\n                  }\n                }\n              },\n            };\n          }\n\n          return {\n            url: authorization.url,\n            instructions:\n              \"Visit the URL above, complete OAuth, then paste either the full redirect URL or the authorization code.\",\n            method: \"code\",\n            callback: async (codeInput: string): Promise<AntigravityTokenExchangeResult> => {\n              const params = parseOAuthCallbackInput(codeInput, fallbackState);\n              if (\"error\" in params) {\n                return { type: \"failed\", error: params.error };\n              }\n\n              const result = await exchangeAntigravity(params.code, params.state);\n              if (result.type === \"success\") {\n                try {\n                  // TUI flow adds to existing accounts (non-destructive)\n                  await persistAccountPool([result], false);\n                } catch {\n                  // ignore\n                }\n\n                // Show appropriate toast message\n                const newTotal = existingCount + 1;\n                const toastMessage = existingCount > 0\n                  ? `Added account${result.email ? ` (${result.email})` : \"\"} - ${newTotal} total`\n                  : `Authenticated${result.email ? ` (${result.email})` : \"\"}`;\n\n                try {\n                  await client.tui.showToast({\n                    body: {\n                      message: toastMessage,\n                      variant: \"success\",\n                    },\n                  });\n                } catch {\n                  // TUI may not be available\n                }\n              }\n\n              return result;\n            },\n          };\n        },\n      },\n      {\n        label: \"Manually enter API Key\",\n        type: \"api\",\n      },\n    ],\n  },\n  };\n};\n\nexport const AntigravityCLIOAuthPlugin = createAntigravityPlugin(ANTIGRAVITY_PROVIDER_ID);\nexport const GoogleOAuthPlugin = AntigravityCLIOAuthPlugin;\n\nfunction toUrlString(value: RequestInfo): string {\n  if (typeof value === \"string\") {\n    return value;\n  }\n  const candidate = (value as Request).url;\n  if (candidate) {\n    return candidate;\n  }\n  return value.toString();\n}\n\nfunction toWarmupStreamUrl(value: RequestInfo): string {\n  const urlString = toUrlString(value);\n  try {\n    const url = new URL(urlString);\n    if (!url.pathname.includes(\":streamGenerateContent\")) {\n      url.pathname = url.pathname.replace(\":generateContent\", \":streamGenerateContent\");\n    }\n    url.searchParams.set(\"alt\", \"sse\");\n    return url.toString();\n  } catch {\n    return urlString;\n  }\n}\n\nfunction extractModelFromUrl(urlString: string): string | null {\n  const match = urlString.match(/\\/models\\/([^:\\/?]+)(?::\\w+)?/);\n  return match?.[1] ?? null;\n}\n\nfunction extractModelFromUrlWithSuffix(urlString: string): string | null {\n  const match = urlString.match(/\\/models\\/([^:\\/\\?]+)/);\n  return match?.[1] ?? null;\n}\n\nfunction getModelFamilyFromUrl(urlString: string): ModelFamily {\n  const model = extractModelFromUrl(urlString);\n  let family: ModelFamily = \"gemini\";\n  if (model && model.includes(\"claude\")) {\n    family = \"claude\";\n  }\n  if (isDebugEnabled()) {\n    logModelFamily(urlString, model, family);\n  }\n  return family;\n}\n\nfunction resolveQuotaFallbackHeaderStyle(input: {\n  family: ModelFamily;\n  headerStyle: HeaderStyle;\n  alternateStyle: HeaderStyle | null;\n}): HeaderStyle | null {\n  if (input.family !== \"gemini\") {\n    return null;\n  }\n  if (!input.alternateStyle || input.alternateStyle === input.headerStyle) {\n    return null;\n  }\n  return input.alternateStyle;\n}\n\ntype HeaderRoutingDecision = {\n  cliFirst: boolean;\n  preferredHeaderStyle: HeaderStyle;\n  explicitQuota: boolean;\n  allowQuotaFallback: boolean;\n};\n\nfunction resolveHeaderRoutingDecision(\n  urlString: string,\n  family: ModelFamily,\n  config: AntigravityConfig,\n): HeaderRoutingDecision {\n  const cliFirst = getCliFirst(config);\n  const preferredHeaderStyle = getHeaderStyleFromUrl(urlString, family, cliFirst);\n  const explicitQuota = isExplicitQuotaFromUrl(urlString);\n  return {\n    cliFirst,\n    preferredHeaderStyle,\n    explicitQuota,\n    allowQuotaFallback: family === \"gemini\",\n  };\n}\n\nfunction getCliFirst(config: AntigravityConfig): boolean {\n  return (config as AntigravityConfig & { cli_first?: boolean }).cli_first ?? false;\n}\n\nfunction getHeaderStyleFromUrl(\n  urlString: string,\n  family: ModelFamily,\n  cliFirst: boolean = false,\n): HeaderStyle {\n  if (family === \"claude\") {\n    return \"antigravity\";\n  }\n  const modelWithSuffix = extractModelFromUrlWithSuffix(urlString);\n  if (!modelWithSuffix) {\n    return cliFirst ? \"gemini-cli\" : \"antigravity\";\n  }\n  const { quotaPreference } = resolveModelWithTier(modelWithSuffix, { cli_first: cliFirst });\n  return quotaPreference ?? \"antigravity\";\n}\n\nfunction isExplicitQuotaFromUrl(urlString: string): boolean {\n  const modelWithSuffix = extractModelFromUrlWithSuffix(urlString);\n  if (!modelWithSuffix) {\n    return false;\n  }\n  const { explicitQuota } = resolveModelWithTier(modelWithSuffix);\n  return explicitQuota ?? false;\n}\n\nexport const __testExports = {\n  getHeaderStyleFromUrl,\n  resolveHeaderRoutingDecision,\n  resolveQuotaFallbackHeaderStyle,\n};\n"
  },
  {
    "path": "src/shims.d.ts",
    "content": "declare module \"@openauthjs/openauth/pkce\" {\n  interface PkcePair {\n    challenge: string;\n    verifier: string;\n  }\n\n  export function generatePKCE(): Promise<PkcePair>;\n}\n"
  },
  {
    "path": "tsconfig.build.json",
    "content": "{\n  \"extends\": \"./tsconfig.json\",\n  \"compilerOptions\": {\n    \"noEmit\": false,\n    \"outDir\": \"dist\",\n    \"declaration\": true,\n    \"declarationMap\": true,\n    \"sourceMap\": true,\n    \"allowImportingTsExtensions\": false\n  },\n  \"include\": [\"src/**/*.ts\", \"src/**/*.tsx\", \"index.ts\"],\n  \"exclude\": [\"src/**/*.test.ts\", \"src/**/*.test.tsx\", \"src/**/*.spec.ts\", \"src/**/*.spec.tsx\"]\n}\n"
  },
  {
    "path": "tsconfig.json",
    "content": "{\n  \"include\": [\"src/**/*\", \"scripts/**/*\"],\n  \"exclude\": [\"node_modules\", \"dist\", \"temp_research\", \"script\"],\n  \"compilerOptions\": {\n    // Environment setup & latest features\n    \"lib\": [\"ESNext\", \"DOM\"],\n    \"target\": \"ESNext\",\n    \"module\": \"Preserve\",\n    \"moduleDetection\": \"force\",\n    \"jsx\": \"react-jsx\",\n    \"allowJs\": true,\n\n    // Bundler mode\n    \"moduleResolution\": \"bundler\",\n    \"typeRoots\": [\"./node_modules/@types\"],\n    \"allowImportingTsExtensions\": true,\n    \"verbatimModuleSyntax\": true,\n    \"noEmit\": true,\n\n    // Best practices\n    \"strict\": true,\n    \"skipLibCheck\": true,\n    \"noFallthroughCasesInSwitch\": true,\n    \"noUncheckedIndexedAccess\": true,\n    \"noImplicitOverride\": true,\n\n    // Some stricter flags (disabled by default)\n    \"noUnusedLocals\": false,\n    \"noUnusedParameters\": false,\n    \"noPropertyAccessFromIndexSignature\": false\n  }\n}\n"
  },
  {
    "path": "vitest.config.ts",
    "content": "import { defineConfig } from 'vitest/config';\n\nexport default defineConfig({\n  test: {\n    globals: true,\n    environment: 'node',\n    include: ['src/**/*.test.ts'],\n    exclude: ['node_modules', 'dist'],\n  },\n});\n"
  }
]