├── src ├── shims.d.ts ├── plugin │ ├── auth.ts │ ├── cli.ts │ ├── types.ts │ ├── token.test.ts │ ├── storage.ts │ ├── cache.ts │ ├── token.ts │ ├── accounts.test.ts │ ├── debug.ts │ ├── auth.test.ts │ ├── accounts.ts │ ├── server.ts │ ├── project.ts │ ├── cache.test.ts │ ├── request-helpers.ts │ └── request-helpers.test.ts ├── constants.ts └── antigravity │ └── oauth.ts ├── vitest.config.ts ├── index.ts ├── tsconfig.build.json ├── .gitignore ├── .github ├── ISSUE_TEMPLATE │ ├── config.yml │ ├── feature_request.md │ └── bug_report.md └── workflows │ ├── ci.yml │ └── release.yml ├── tsconfig.json ├── LICENSE ├── package.json ├── AGENTS.MD ├── README.md └── docs ├── ANTIGRAVITY_API_SPEC.md └── CLAUDE_MODEL_FLOW.md /src/shims.d.ts: -------------------------------------------------------------------------------- 1 | declare module "@openauthjs/openauth/pkce" { 2 | interface PkcePair { 3 | challenge: string; 4 | verifier: string; 5 | } 6 | 7 | export function generatePKCE(): Promise; 8 | } 9 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vitest/config'; 2 | 3 | export default defineConfig({ 4 | test: { 5 | globals: true, 6 | environment: 'node', 7 | include: ['src/**/*.test.ts'], 8 | exclude: ['node_modules', 'dist'], 9 | }, 10 | }); 11 | -------------------------------------------------------------------------------- /index.ts: -------------------------------------------------------------------------------- 1 | export { 2 | AntigravityCLIOAuthPlugin, 3 | GoogleOAuthPlugin, 4 | } from "./src/plugin"; 5 | 6 | export { 7 | authorizeAntigravity, 8 | exchangeAntigravity, 9 | } from "./src/antigravity/oauth"; 10 | 11 | export type { 12 | AntigravityAuthorization, 13 | AntigravityTokenExchangeResult, 14 | } from "./src/antigravity/oauth"; 15 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "noEmit": false, 5 | "outDir": "dist", 6 | "declaration": true, 7 | "declarationMap": true, 8 | "sourceMap": true, 9 | "allowImportingTsExtensions": false 10 | }, 11 | "include": ["src/**/*.ts", "src/**/*.tsx", "index.ts"], 12 | "exclude": ["src/**/*.test.ts", "src/**/*.test.tsx", "src/**/*.spec.ts", "src/**/*.spec.tsx"] 13 | } 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # dependencies (bun install) 2 | node_modules 3 | 4 | # output 5 | out 6 | dist 7 | *.tgz 8 | 9 | # code coverage 10 | coverage 11 | *.lcov 12 | 13 | # logs 14 | logs 15 | _.log 16 | report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json 17 | antigravity-debug-*.log 18 | 19 | # dotenv environment variable files 20 | .env 21 | .env.development.local 22 | .env.test.local 23 | .env.production.local 24 | .env.local 25 | 26 | # caches 27 | .eslintcache 28 | .cache 29 | *.tsbuildinfo 30 | 31 | # IntelliJ based IDEs 32 | .idea 33 | 34 | # Finder (MacOS) folder config 35 | .DS_Store 36 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: 3 | - name: Google Cloud Support 4 | url: https://cloud.google.com/support 5 | about: For Google Cloud account or billing issues, contact Google Cloud directly 6 | - name: Google Terms of Service 7 | url: https://policies.google.com/terms 8 | about: Review Google's Terms of Service for compliance questions 9 | - name: Discussions 10 | url: https://github.com/NoeFabris/opencode-antigravity-auth/discussions 11 | about: Ask questions or discuss the plugin with the community 12 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | branches: [main] 8 | 9 | jobs: 10 | test: 11 | name: Test on Node.js 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - name: Checkout code 16 | uses: actions/checkout@v4 17 | 18 | - name: Setup Node.js 19 | uses: actions/setup-node@v4 20 | with: 21 | node-version: 20 22 | cache: 'npm' 23 | 24 | - name: Install dependencies 25 | run: npm ci 26 | 27 | - name: Run type check 28 | run: npm run typecheck 29 | 30 | - name: Run tests 31 | run: npm test 32 | 33 | - name: Build 34 | run: npm run build 35 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | // Environment setup & latest features 4 | "lib": ["ESNext", "DOM"], 5 | "target": "ESNext", 6 | "module": "Preserve", 7 | "moduleDetection": "force", 8 | "jsx": "react-jsx", 9 | "allowJs": true, 10 | 11 | // Bundler mode 12 | "moduleResolution": "bundler", 13 | "allowImportingTsExtensions": true, 14 | "verbatimModuleSyntax": true, 15 | "noEmit": true, 16 | 17 | // Best practices 18 | "strict": true, 19 | "skipLibCheck": true, 20 | "noFallthroughCasesInSwitch": true, 21 | "noUncheckedIndexedAccess": true, 22 | "noImplicitOverride": true, 23 | 24 | // Some stricter flags (disabled by default) 25 | "noUnusedLocals": false, 26 | "noUnusedParameters": false, 27 | "noPropertyAccessFromIndexSignature": false 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature Request 3 | about: Suggest a new feature or enhancement 4 | title: '[FEATURE] ' 5 | labels: enhancement 6 | assignees: '' 7 | --- 8 | 9 | **Feature Description** 10 | A clear description of the feature you'd like to see. 11 | 12 | **Use Case** 13 | Explain how this feature would be used and what problem it solves. 14 | 15 | **Proposed Implementation** 16 | If you have ideas about how this could be implemented, share them here. 17 | 18 | **Compliance Consideration** 19 | Please confirm: 20 | - [ ] This feature is for personal development use 21 | - [ ] This feature does not violate or circumvent Google's Terms of Service 22 | - [ ] This feature is not intended for commercial resale or multi-user access 23 | 24 | **Alternatives Considered** 25 | Have you considered any alternative solutions or workarounds? 26 | 27 | **Additional Context** 28 | Add any other context, screenshots, or examples. 29 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug Report 3 | about: Report a bug or issue with the plugin 4 | title: '[BUG] ' 5 | labels: bug 6 | assignees: '' 7 | --- 8 | 9 | **Bug Description** 10 | A clear and concise description of the bug. 11 | 12 | **Steps to Reproduce** 13 | 1. 14 | 2. 15 | 3. 16 | 17 | **Expected Behavior** 18 | What should happen. 19 | 20 | **Actual Behavior** 21 | What actually happens. 22 | 23 | **Environment** 24 | - opencode version: 25 | - Plugin version: 26 | - Operating System: 27 | - Node.js version: 28 | 29 | **Logs** 30 | If applicable, attach logs (enable verbose logging with `export OPENCODE_ANTIGRAVITY_DEBUG=1`). Logs are saved in the current directory as `antigravity-debug-.log`. 31 | 32 | **Compliance Checklist** 33 | Please confirm: 34 | - [ ] I'm using this plugin for personal development only 35 | - [ ] I have an active Google Cloud project with Antigravity enabled 36 | - [ ] This issue is not related to attempting commercial use or TOS violations 37 | - [ ] I've reviewed the README troubleshooting section 38 | 39 | **Additional Context** 40 | Add any other relevant information. 41 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Jens 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/plugin/auth.ts: -------------------------------------------------------------------------------- 1 | import type { AuthDetails, OAuthAuthDetails, RefreshParts } from "./types"; 2 | 3 | const ACCESS_TOKEN_EXPIRY_BUFFER_MS = 60 * 1000; 4 | 5 | export function isOAuthAuth(auth: AuthDetails): auth is OAuthAuthDetails { 6 | return auth.type === "oauth"; 7 | } 8 | 9 | /** 10 | * Splits a packed refresh string into its constituent refresh token and project IDs. 11 | */ 12 | export function parseRefreshParts(refresh: string): RefreshParts { 13 | const [refreshToken = "", projectId = "", managedProjectId = ""] = (refresh ?? "").split("|"); 14 | return { 15 | refreshToken, 16 | projectId: projectId || undefined, 17 | managedProjectId: managedProjectId || undefined, 18 | }; 19 | } 20 | 21 | /** 22 | * Serializes refresh token parts into the stored string format. 23 | */ 24 | export function formatRefreshParts(parts: RefreshParts): string { 25 | const projectSegment = parts.projectId ?? ""; 26 | const base = `${parts.refreshToken}|${projectSegment}`; 27 | return parts.managedProjectId ? `${base}|${parts.managedProjectId}` : base; 28 | } 29 | 30 | /** 31 | * Determines whether an access token is expired or missing, with buffer for clock skew. 32 | */ 33 | export function accessTokenExpired(auth: OAuthAuthDetails): boolean { 34 | if (!auth.access || typeof auth.expires !== "number") { 35 | return true; 36 | } 37 | return auth.expires <= Date.now() + ACCESS_TOKEN_EXPIRY_BUFFER_MS; 38 | } 39 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "opencode-antigravity-auth", 3 | "version": "1.2.0", 4 | "description": "Google Antigravity IDE OAuth auth plugin for Opencode - access Gemini 3 Pro and Claude 4.5 using Google credentials", 5 | "main": "./dist/index.js", 6 | "types": "./dist/index.d.ts", 7 | "type": "module", 8 | "license": "MIT", 9 | "author": "noefabris", 10 | "repository": { 11 | "type": "git", 12 | "url": "git+https://github.com/NoeFabris/opencode-antigravity-auth.git" 13 | }, 14 | "homepage": "https://github.com/NoeFabris/opencode-antigravity-auth#readme", 15 | "bugs": { 16 | "url": "https://github.com/NoeFabris/opencode-antigravity-auth/issues" 17 | }, 18 | "keywords": [ 19 | "opencode", 20 | "google", 21 | "antigravity", 22 | "gemini", 23 | "oauth", 24 | "plugin", 25 | "auth", 26 | "claude" 27 | ], 28 | "engines": { 29 | "node": ">=20.0.0" 30 | }, 31 | "files": [ 32 | "dist/", 33 | "README.md", 34 | "LICENSE" 35 | ], 36 | "scripts": { 37 | "build": "tsc -p tsconfig.build.json", 38 | "typecheck": "tsc --noEmit", 39 | "test": "vitest run", 40 | "test:watch": "vitest", 41 | "test:ui": "vitest --ui", 42 | "test:coverage": "vitest run --coverage", 43 | "prepublishOnly": "npm run build" 44 | }, 45 | "peerDependencies": { 46 | "typescript": "^5" 47 | }, 48 | "devDependencies": { 49 | "@opencode-ai/plugin": "^0.15.30", 50 | "@types/node": "^24.10.1", 51 | "@vitest/ui": "^3.0.0", 52 | "typescript": "^5.0.0", 53 | "vitest": "^3.0.0" 54 | }, 55 | "dependencies": { 56 | "@openauthjs/openauth": "^0.4.3" 57 | } 58 | } -------------------------------------------------------------------------------- /src/plugin/cli.ts: -------------------------------------------------------------------------------- 1 | import { createInterface } from "node:readline/promises"; 2 | import { stdin as input, stdout as output } from "node:process"; 3 | 4 | /** 5 | * Prompts the user for a project ID via stdin/stdout. 6 | */ 7 | export async function promptProjectId(): Promise { 8 | const rl = createInterface({ input, output }); 9 | try { 10 | const answer = await rl.question("Project ID (leave blank to use your default project): "); 11 | return answer.trim(); 12 | } finally { 13 | rl.close(); 14 | } 15 | } 16 | 17 | /** 18 | * Prompts user whether they want to add another OAuth account. 19 | */ 20 | export async function promptAddAnotherAccount(currentCount: number): Promise { 21 | const rl = createInterface({ input, output }); 22 | try { 23 | const answer = await rl.question(`Add another account? (${currentCount} added) (y/n): `); 24 | const normalized = answer.trim().toLowerCase(); 25 | return normalized === "y" || normalized === "yes"; 26 | } finally { 27 | rl.close(); 28 | } 29 | } 30 | 31 | export type LoginMode = "add" | "fresh"; 32 | 33 | export interface ExistingAccountInfo { 34 | email?: string; 35 | index: number; 36 | } 37 | 38 | /** 39 | * Prompts user to choose login mode when accounts already exist. 40 | * Returns "add" to append new accounts, "fresh" to clear and start over. 41 | */ 42 | export async function promptLoginMode(existingAccounts: ExistingAccountInfo[]): Promise { 43 | const rl = createInterface({ input, output }); 44 | try { 45 | console.log(`\n${existingAccounts.length} account(s) saved:`); 46 | for (const acc of existingAccounts) { 47 | const label = acc.email || `Account ${acc.index + 1}`; 48 | console.log(` ${acc.index + 1}. ${label}`); 49 | } 50 | console.log(""); 51 | 52 | while (true) { 53 | const answer = await rl.question("(a)dd new account(s) or (f)resh start? [a/f]: "); 54 | const normalized = answer.trim().toLowerCase(); 55 | 56 | if (normalized === "a" || normalized === "add") { 57 | return "add"; 58 | } 59 | if (normalized === "f" || normalized === "fresh") { 60 | return "fresh"; 61 | } 62 | 63 | console.log("Please enter 'a' to add accounts or 'f' to start fresh."); 64 | } 65 | } finally { 66 | rl.close(); 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /AGENTS.MD: -------------------------------------------------------------------------------- 1 | # AGENTS.md 2 | 3 | This file provides coding guidance for AI agents when working with code in this repository. 4 | 5 | ## Overview 6 | 7 | This is an **opencode plugin** that enables OAuth authentication with Google's Antigravity IDE backend. It allows users to access models like `gemini-3-pro-high` and `claude-sonnet-4-5` using their Google credentials. 8 | 9 | ## Build & Test Commands 10 | 11 | ```bash 12 | # Install dependencies 13 | npm install 14 | 15 | # Build (compiles TypeScript) 16 | npm run build 17 | 18 | # Type checking only 19 | npm run typecheck 20 | 21 | # Run tests 22 | npm test 23 | ``` 24 | 25 | ## Code Architecture 26 | 27 | ### Plugin Flow (src/plugin.ts) 28 | 29 | The main entry point `createAntigravityPlugin` orchestrates a request interception flow: 30 | 31 | 1. **Auth Validation**: Checks if the request is for the Antigravity provider and if OAuth is used. 32 | 2. **Token Refresh**: Checks for expired access tokens and refreshes them using `refreshAccessToken`. 33 | 3. **Project Context**: Resolves the effective Google Cloud project ID. 34 | 4. **Endpoint Fallback**: Tries multiple Antigravity endpoints in sequence (`daily` → `autopush` → `prod`) if requests fail with specific error codes (403, 404, 429, 5xx). 35 | 5. **Response Transformation**: Converts the Antigravity response format to what opencode expects. 36 | 37 | ### Module Organization 38 | 39 | **Core Plugin** (`src/plugin.ts`) 40 | - Plugin definition and request interception logic. 41 | - Implements endpoint fallback strategy. 42 | 43 | **Authentication** (`src/antigravity/oauth.ts`, `src/plugin/auth.ts`) 44 | - Handles OAuth flow, token exchange, and token validation. 45 | - `src/plugin/server.ts`: Local HTTP server for OAuth callback. 46 | 47 | **Request Handling** (`src/plugin/request.ts`) 48 | - `prepareAntigravityRequest`: Prepares headers and body for Antigravity API. 49 | - `transformAntigravityResponse`: Handles response transformation. 50 | 51 | **Configuration** (`src/constants.ts`) 52 | - Contains constants like provider IDs, redirect URIs, and endpoint URLs. 53 | 54 | ## Key Design Patterns 55 | 56 | **1. Endpoint Fallback**: 57 | - The plugin robustly handles service availability by iterating through a list of fallback endpoints defined in `ANTIGRAVITY_ENDPOINT_FALLBACKS`. 58 | 59 | **2. Automatic Token Refresh**: 60 | - Access tokens are automatically checked for expiration and refreshed before requests are sent. 61 | 62 | **3. Debug Logging**: 63 | - Extensive debug logging is available via `startAntigravityDebugRequest` when enabled. 64 | 65 | ## TypeScript Configuration 66 | 67 | - Target: `ESNext` 68 | - Module: `Preserve` 69 | - Module Resolution: `bundler` 70 | - Strict mode enabled. 71 | 72 | ## Dependencies 73 | 74 | - `@openauthjs/openauth`: Handles OAuth PKCE implementation. 75 | - `vitest`: Testing framework. 76 | - `typescript`: Peer dependency. 77 | -------------------------------------------------------------------------------- /src/plugin/types.ts: -------------------------------------------------------------------------------- 1 | import type { PluginInput } from "@opencode-ai/plugin"; 2 | import type { AntigravityTokenExchangeResult } from "../antigravity/oauth"; 3 | 4 | export interface OAuthAuthDetails { 5 | type: "oauth"; 6 | refresh: string; 7 | access?: string; 8 | expires?: number; 9 | } 10 | 11 | export interface ApiKeyAuthDetails { 12 | type: "api_key"; 13 | key: string; 14 | } 15 | 16 | export interface NonOAuthAuthDetails { 17 | type: string; 18 | [key: string]: unknown; 19 | } 20 | 21 | export type AuthDetails = OAuthAuthDetails | ApiKeyAuthDetails | NonOAuthAuthDetails; 22 | 23 | export type GetAuth = () => Promise; 24 | 25 | export interface ProviderModel { 26 | cost?: { 27 | input: number; 28 | output: number; 29 | }; 30 | [key: string]: unknown; 31 | } 32 | 33 | export interface Provider { 34 | models?: Record; 35 | } 36 | 37 | export interface LoaderResult { 38 | apiKey: string; 39 | fetch(input: RequestInfo, init?: RequestInit): Promise; 40 | } 41 | 42 | export type PluginClient = PluginInput["client"]; 43 | 44 | export interface PluginContext { 45 | client: PluginClient; 46 | } 47 | 48 | export type AuthPrompt = 49 | | { 50 | type: "text"; 51 | key: string; 52 | message: string; 53 | placeholder?: string; 54 | validate?: (value: string) => string | undefined; 55 | condition?: (inputs: Record) => boolean; 56 | } 57 | | { 58 | type: "select"; 59 | key: string; 60 | message: string; 61 | options: Array<{ label: string; value: string; hint?: string }>; 62 | condition?: (inputs: Record) => boolean; 63 | }; 64 | 65 | export type OAuthAuthorizationResult = { url: string; instructions: string } & ( 66 | | { 67 | method: "auto"; 68 | callback: () => Promise; 69 | } 70 | | { 71 | method: "code"; 72 | callback: (code: string) => Promise; 73 | } 74 | ); 75 | 76 | export interface AuthMethod { 77 | provider?: string; 78 | label: string; 79 | type: "oauth" | "api"; 80 | prompts?: AuthPrompt[]; 81 | authorize?: (inputs?: Record) => Promise; 82 | } 83 | 84 | export interface PluginResult { 85 | auth: { 86 | provider: string; 87 | loader: (getAuth: GetAuth, provider: Provider) => Promise>; 88 | methods: AuthMethod[]; 89 | }; 90 | } 91 | 92 | export interface RefreshParts { 93 | refreshToken: string; 94 | projectId?: string; 95 | managedProjectId?: string; 96 | } 97 | 98 | export interface ProjectContextResult { 99 | auth: OAuthAuthDetails; 100 | effectiveProjectId: string; 101 | } 102 | 103 | -------------------------------------------------------------------------------- /src/plugin/token.test.ts: -------------------------------------------------------------------------------- 1 | import { beforeEach, describe, expect, it, vi } from "vitest"; 2 | 3 | import { ANTIGRAVITY_PROVIDER_ID } from "../constants"; 4 | import { AntigravityTokenRefreshError, refreshAccessToken } from "./token"; 5 | import type { OAuthAuthDetails, PluginClient } from "./types"; 6 | 7 | const baseAuth: OAuthAuthDetails = { 8 | type: "oauth", 9 | refresh: "refresh-token|project-123", 10 | access: "old-access", 11 | expires: Date.now() - 1000, 12 | }; 13 | 14 | function createClient() { 15 | return { 16 | auth: { 17 | set: vi.fn(async () => {}), 18 | }, 19 | } as PluginClient & { 20 | auth: { set: ReturnType }; 21 | }; 22 | } 23 | 24 | describe("refreshAccessToken", () => { 25 | beforeEach(() => { 26 | vi.restoreAllMocks(); 27 | }); 28 | 29 | it("updates the caller when refresh token is unchanged", async () => { 30 | const client = createClient(); 31 | const fetchMock = vi.fn(async () => { 32 | return new Response( 33 | JSON.stringify({ 34 | access_token: "new-access", 35 | expires_in: 3600, 36 | }), 37 | { status: 200 }, 38 | ); 39 | }); 40 | global.fetch = fetchMock as unknown as typeof fetch; 41 | 42 | const result = await refreshAccessToken(baseAuth, client, ANTIGRAVITY_PROVIDER_ID); 43 | 44 | expect(result?.access).toBe("new-access"); 45 | expect(client.auth.set.mock.calls.length).toBe(0); 46 | }); 47 | 48 | it("handles Google refresh token rotation", async () => { 49 | const client = createClient(); 50 | const fetchMock = vi.fn(async () => { 51 | return new Response( 52 | JSON.stringify({ 53 | access_token: "next-access", 54 | expires_in: 3600, 55 | refresh_token: "rotated-token", 56 | }), 57 | { status: 200 }, 58 | ); 59 | }); 60 | global.fetch = fetchMock as unknown as typeof fetch; 61 | 62 | const result = await refreshAccessToken(baseAuth, client, ANTIGRAVITY_PROVIDER_ID); 63 | 64 | expect(result?.access).toBe("next-access"); 65 | expect(result?.refresh).toContain("rotated-token"); 66 | expect(client.auth.set.mock.calls.length).toBe(0); 67 | }); 68 | 69 | it("throws a typed error on invalid_grant", async () => { 70 | const client = createClient(); 71 | const fetchMock = vi.fn(async () => { 72 | return new Response( 73 | JSON.stringify({ 74 | error: "invalid_grant", 75 | error_description: "Refresh token revoked", 76 | }), 77 | { status: 400, statusText: "Bad Request" }, 78 | ); 79 | }); 80 | global.fetch = fetchMock as unknown as typeof fetch; 81 | 82 | await expect(refreshAccessToken(baseAuth, client, ANTIGRAVITY_PROVIDER_ID)).rejects.toMatchObject({ 83 | name: "AntigravityTokenRefreshError", 84 | code: "invalid_grant", 85 | }); 86 | }); 87 | }); 88 | -------------------------------------------------------------------------------- /src/plugin/storage.ts: -------------------------------------------------------------------------------- 1 | import { promises as fs } from "node:fs"; 2 | import { dirname, join } from "node:path"; 3 | import { homedir } from "node:os"; 4 | 5 | export interface AccountMetadata { 6 | email?: string; 7 | refreshToken: string; 8 | projectId?: string; 9 | managedProjectId?: string; 10 | addedAt: number; 11 | lastUsed: number; 12 | isRateLimited?: boolean; 13 | rateLimitResetTime?: number; 14 | } 15 | 16 | export interface AccountStorage { 17 | version: 1; 18 | accounts: AccountMetadata[]; 19 | /** 20 | * Rotation cursor (next index to start from). 21 | * 22 | * Historical note: some forks call this `activeIndex`. 23 | */ 24 | activeIndex: number; 25 | } 26 | 27 | function getConfigDir(): string { 28 | const platform = process.platform; 29 | if (platform === "win32") { 30 | return join(process.env.APPDATA || join(homedir(), "AppData", "Roaming"), "opencode"); 31 | } 32 | 33 | const xdgConfig = process.env.XDG_CONFIG_HOME || join(homedir(), ".config"); 34 | return join(xdgConfig, "opencode"); 35 | } 36 | 37 | export function getStoragePath(): string { 38 | return join(getConfigDir(), "antigravity-accounts.json"); 39 | } 40 | 41 | export async function loadAccounts(): Promise { 42 | try { 43 | const path = getStoragePath(); 44 | const content = await fs.readFile(path, "utf-8"); 45 | const parsed = JSON.parse(content) as Partial; 46 | 47 | if (parsed.version !== 1 || !Array.isArray(parsed.accounts)) { 48 | console.warn("[opencode-antigravity-auth] Invalid account storage format, ignoring"); 49 | return null; 50 | } 51 | 52 | return { 53 | version: 1, 54 | accounts: parsed.accounts.filter((a): a is AccountMetadata => { 55 | return !!a && typeof a === "object" && typeof (a as AccountMetadata).refreshToken === "string"; 56 | }), 57 | activeIndex: typeof parsed.activeIndex === "number" && Number.isFinite(parsed.activeIndex) ? parsed.activeIndex : 0, 58 | }; 59 | } catch (error) { 60 | const code = (error as NodeJS.ErrnoException).code; 61 | if (code === "ENOENT") { 62 | return null; 63 | } 64 | console.error("[opencode-antigravity-auth] Failed to load account storage:", error); 65 | return null; 66 | } 67 | } 68 | 69 | export async function saveAccounts(storage: AccountStorage): Promise { 70 | const path = getStoragePath(); 71 | await fs.mkdir(dirname(path), { recursive: true }); 72 | 73 | const content = JSON.stringify(storage, null, 2); 74 | await fs.writeFile(path, content, "utf-8"); 75 | } 76 | 77 | export async function clearAccounts(): Promise { 78 | try { 79 | const path = getStoragePath(); 80 | await fs.unlink(path); 81 | } catch (error) { 82 | const code = (error as NodeJS.ErrnoException).code; 83 | if (code !== "ENOENT") { 84 | console.error("[opencode-antigravity-auth] Failed to clear account storage:", error); 85 | } 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Constants used for Antigravity OAuth flows and Cloud Code Assist API integration. 3 | */ 4 | export const ANTIGRAVITY_CLIENT_ID = "1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com"; 5 | 6 | /** 7 | * Client secret issued for the Antigravity OAuth application. 8 | */ 9 | export const ANTIGRAVITY_CLIENT_SECRET = "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"; 10 | 11 | /** 12 | * Scopes required for Antigravity integrations. 13 | */ 14 | export const ANTIGRAVITY_SCOPES: readonly string[] = [ 15 | "https://www.googleapis.com/auth/cloud-platform", 16 | "https://www.googleapis.com/auth/userinfo.email", 17 | "https://www.googleapis.com/auth/userinfo.profile", 18 | "https://www.googleapis.com/auth/cclog", 19 | "https://www.googleapis.com/auth/experimentsandconfigs", 20 | ]; 21 | 22 | /** 23 | * OAuth redirect URI used by the local CLI callback server. 24 | */ 25 | export const ANTIGRAVITY_REDIRECT_URI = "http://localhost:51121/oauth-callback"; 26 | 27 | /** 28 | * Root endpoints for the Antigravity API (in fallback order). 29 | * CLIProxy and Vibeproxy use the daily sandbox endpoint first, 30 | * then fallback to autopush and prod if needed. 31 | */ 32 | export const ANTIGRAVITY_ENDPOINT_DAILY = "https://daily-cloudcode-pa.sandbox.googleapis.com"; 33 | export const ANTIGRAVITY_ENDPOINT_AUTOPUSH = "https://autopush-cloudcode-pa.sandbox.googleapis.com"; 34 | export const ANTIGRAVITY_ENDPOINT_PROD = "https://cloudcode-pa.googleapis.com"; 35 | 36 | /** 37 | * Endpoint fallback order (daily → autopush → prod). 38 | * Shared across request handling and project discovery to mirror CLIProxy behavior. 39 | */ 40 | export const ANTIGRAVITY_ENDPOINT_FALLBACKS = [ 41 | ANTIGRAVITY_ENDPOINT_DAILY, 42 | ANTIGRAVITY_ENDPOINT_AUTOPUSH, 43 | ANTIGRAVITY_ENDPOINT_PROD, 44 | ] as const; 45 | 46 | /** 47 | * Preferred endpoint order for project discovery (prod first, then fallbacks). 48 | * loadCodeAssist appears to be best supported on prod for managed project resolution. 49 | */ 50 | export const ANTIGRAVITY_LOAD_ENDPOINTS = [ 51 | ANTIGRAVITY_ENDPOINT_PROD, 52 | ANTIGRAVITY_ENDPOINT_DAILY, 53 | ANTIGRAVITY_ENDPOINT_AUTOPUSH, 54 | ] as const; 55 | 56 | /** 57 | * Primary endpoint to use (daily sandbox - same as CLIProxy/Vibeproxy). 58 | */ 59 | export const ANTIGRAVITY_ENDPOINT = ANTIGRAVITY_ENDPOINT_DAILY; 60 | 61 | /** 62 | * Hardcoded project id used when Antigravity does not return one (e.g., business/workspace accounts). 63 | */ 64 | export const ANTIGRAVITY_DEFAULT_PROJECT_ID = "rising-fact-p41fc"; 65 | 66 | export const ANTIGRAVITY_HEADERS = { 67 | "User-Agent": "antigravity/1.11.5 windows/amd64", 68 | "X-Goog-Api-Client": "google-cloud-sdk vscode_cloudshelleditor/0.1", 69 | "Client-Metadata": '{"ideType":"IDE_UNSPECIFIED","platform":"PLATFORM_UNSPECIFIED","pluginType":"GEMINI"}', 70 | } as const; 71 | 72 | /** 73 | * Provider identifier shared between the plugin loader and credential store. 74 | */ 75 | export const ANTIGRAVITY_PROVIDER_ID = "google"; 76 | -------------------------------------------------------------------------------- /src/plugin/cache.ts: -------------------------------------------------------------------------------- 1 | import { accessTokenExpired } from "./auth"; 2 | import type { OAuthAuthDetails } from "./types"; 3 | import { createHash } from "node:crypto"; 4 | 5 | const authCache = new Map(); 6 | 7 | /** 8 | * Produces a stable cache key from a refresh token string. 9 | */ 10 | function normalizeRefreshKey(refresh?: string): string | undefined { 11 | const key = refresh?.trim(); 12 | return key ? key : undefined; 13 | } 14 | 15 | /** 16 | * Returns a cached auth snapshot when available, favoring unexpired tokens. 17 | */ 18 | export function resolveCachedAuth(auth: OAuthAuthDetails): OAuthAuthDetails { 19 | const key = normalizeRefreshKey(auth.refresh); 20 | if (!key) { 21 | return auth; 22 | } 23 | 24 | const cached = authCache.get(key); 25 | if (!cached) { 26 | authCache.set(key, auth); 27 | return auth; 28 | } 29 | 30 | if (!accessTokenExpired(auth)) { 31 | authCache.set(key, auth); 32 | return auth; 33 | } 34 | 35 | if (!accessTokenExpired(cached)) { 36 | return cached; 37 | } 38 | 39 | authCache.set(key, auth); 40 | return auth; 41 | } 42 | 43 | /** 44 | * Stores the latest auth snapshot keyed by refresh token. 45 | */ 46 | export function storeCachedAuth(auth: OAuthAuthDetails): void { 47 | const key = normalizeRefreshKey(auth.refresh); 48 | if (!key) { 49 | return; 50 | } 51 | authCache.set(key, auth); 52 | } 53 | 54 | /** 55 | * Clears cached auth globally or for a specific refresh token. 56 | */ 57 | export function clearCachedAuth(refresh?: string): void { 58 | if (!refresh) { 59 | authCache.clear(); 60 | return; 61 | } 62 | const key = normalizeRefreshKey(refresh); 63 | if (key) { 64 | authCache.delete(key); 65 | } 66 | } 67 | 68 | // ============================================================================ 69 | // Thinking Signature Cache (for Claude multi-turn conversations) 70 | // ============================================================================ 71 | 72 | interface SignatureEntry { 73 | signature: string; 74 | timestamp: number; 75 | } 76 | 77 | // Map: sessionId -> Map 78 | const signatureCache = new Map>(); 79 | 80 | // Cache entries expire after 1 hour 81 | const SIGNATURE_CACHE_TTL_MS = 60 * 60 * 1000; 82 | 83 | // Maximum entries per session to prevent memory bloat 84 | const MAX_ENTRIES_PER_SESSION = 100; 85 | 86 | // 16 hex chars = 64-bit key space; keeps memory bounded while making collisions extremely unlikely. 87 | const SIGNATURE_TEXT_HASH_HEX_LEN = 16; 88 | 89 | /** 90 | * Hashes text content into a stable, Unicode-safe key. 91 | * 92 | * Uses SHA-256 over UTF-8 bytes and truncates to keep memory usage bounded. 93 | */ 94 | function hashText(text: string): string { 95 | return createHash("sha256").update(text, "utf8").digest("hex").slice(0, SIGNATURE_TEXT_HASH_HEX_LEN); 96 | } 97 | 98 | /** 99 | * Caches a thinking signature for a given session and text. 100 | * Used for Claude models that require signed thinking blocks in multi-turn conversations. 101 | */ 102 | export function cacheSignature(sessionId: string, text: string, signature: string): void { 103 | if (!sessionId || !text || !signature) return; 104 | 105 | let sessionCache = signatureCache.get(sessionId); 106 | if (!sessionCache) { 107 | sessionCache = new Map(); 108 | signatureCache.set(sessionId, sessionCache); 109 | } 110 | 111 | // Evict old entries if we're at capacity 112 | if (sessionCache.size >= MAX_ENTRIES_PER_SESSION) { 113 | const now = Date.now(); 114 | for (const [key, entry] of sessionCache.entries()) { 115 | if (now - entry.timestamp > SIGNATURE_CACHE_TTL_MS) { 116 | sessionCache.delete(key); 117 | } 118 | } 119 | // If still at capacity, remove oldest entries 120 | if (sessionCache.size >= MAX_ENTRIES_PER_SESSION) { 121 | const entries = Array.from(sessionCache.entries()) 122 | .sort((a, b) => a[1].timestamp - b[1].timestamp); 123 | const toRemove = entries.slice(0, Math.floor(MAX_ENTRIES_PER_SESSION / 4)); 124 | for (const [key] of toRemove) { 125 | sessionCache.delete(key); 126 | } 127 | } 128 | } 129 | 130 | const textHash = hashText(text); 131 | sessionCache.set(textHash, { signature, timestamp: Date.now() }); 132 | } 133 | 134 | /** 135 | * Retrieves a cached signature for a given session and text. 136 | * Returns undefined if not found or expired. 137 | */ 138 | export function getCachedSignature(sessionId: string, text: string): string | undefined { 139 | if (!sessionId || !text) return undefined; 140 | 141 | const sessionCache = signatureCache.get(sessionId); 142 | if (!sessionCache) return undefined; 143 | 144 | const textHash = hashText(text); 145 | const entry = sessionCache.get(textHash); 146 | if (!entry) return undefined; 147 | 148 | // Check if expired 149 | if (Date.now() - entry.timestamp > SIGNATURE_CACHE_TTL_MS) { 150 | sessionCache.delete(textHash); 151 | return undefined; 152 | } 153 | 154 | return entry.signature; 155 | } 156 | 157 | /** 158 | * Clears signature cache for a specific session or all sessions. 159 | */ 160 | export function clearSignatureCache(sessionId?: string): void { 161 | if (sessionId) { 162 | signatureCache.delete(sessionId); 163 | } else { 164 | signatureCache.clear(); 165 | } 166 | } 167 | -------------------------------------------------------------------------------- /src/plugin/token.ts: -------------------------------------------------------------------------------- 1 | import { ANTIGRAVITY_CLIENT_ID, ANTIGRAVITY_CLIENT_SECRET } from "../constants"; 2 | import { formatRefreshParts, parseRefreshParts } from "./auth"; 3 | import { clearCachedAuth, storeCachedAuth } from "./cache"; 4 | import { invalidateProjectContextCache } from "./project"; 5 | import type { OAuthAuthDetails, PluginClient, RefreshParts } from "./types"; 6 | 7 | interface OAuthErrorPayload { 8 | error?: 9 | | string 10 | | { 11 | code?: string; 12 | status?: string; 13 | message?: string; 14 | }; 15 | error_description?: string; 16 | } 17 | 18 | /** 19 | * Parses OAuth error payloads returned by Google token endpoints, tolerating varied shapes. 20 | */ 21 | function parseOAuthErrorPayload(text: string | undefined): { code?: string; description?: string } { 22 | if (!text) { 23 | return {}; 24 | } 25 | 26 | try { 27 | const payload = JSON.parse(text) as OAuthErrorPayload; 28 | if (!payload || typeof payload !== "object") { 29 | return { description: text }; 30 | } 31 | 32 | let code: string | undefined; 33 | if (typeof payload.error === "string") { 34 | code = payload.error; 35 | } else if (payload.error && typeof payload.error === "object") { 36 | code = payload.error.status ?? payload.error.code; 37 | if (!payload.error_description && payload.error.message) { 38 | return { code, description: payload.error.message }; 39 | } 40 | } 41 | 42 | const description = payload.error_description; 43 | if (description) { 44 | return { code, description }; 45 | } 46 | 47 | if (payload.error && typeof payload.error === "object" && payload.error.message) { 48 | return { code, description: payload.error.message }; 49 | } 50 | 51 | return { code }; 52 | } catch { 53 | return { description: text }; 54 | } 55 | } 56 | 57 | export class AntigravityTokenRefreshError extends Error { 58 | code?: string; 59 | description?: string; 60 | status: number; 61 | statusText: string; 62 | 63 | constructor(options: { 64 | message: string; 65 | code?: string; 66 | description?: string; 67 | status: number; 68 | statusText: string; 69 | }) { 70 | super(options.message); 71 | this.name = "AntigravityTokenRefreshError"; 72 | this.code = options.code; 73 | this.description = options.description; 74 | this.status = options.status; 75 | this.statusText = options.statusText; 76 | } 77 | } 78 | 79 | /** 80 | * Refreshes an Antigravity OAuth access token, updates persisted credentials, and handles revocation. 81 | */ 82 | export async function refreshAccessToken( 83 | auth: OAuthAuthDetails, 84 | client: PluginClient, 85 | providerId: string, 86 | ): Promise { 87 | const parts = parseRefreshParts(auth.refresh); 88 | if (!parts.refreshToken) { 89 | return undefined; 90 | } 91 | 92 | try { 93 | const response = await fetch("https://oauth2.googleapis.com/token", { 94 | method: "POST", 95 | headers: { 96 | "Content-Type": "application/x-www-form-urlencoded", 97 | }, 98 | body: new URLSearchParams({ 99 | grant_type: "refresh_token", 100 | refresh_token: parts.refreshToken, 101 | client_id: ANTIGRAVITY_CLIENT_ID, 102 | client_secret: ANTIGRAVITY_CLIENT_SECRET, 103 | }), 104 | }); 105 | 106 | if (!response.ok) { 107 | let errorText: string | undefined; 108 | try { 109 | errorText = await response.text(); 110 | } catch { 111 | errorText = undefined; 112 | } 113 | 114 | const { code, description } = parseOAuthErrorPayload(errorText); 115 | const details = [code, description ?? errorText].filter(Boolean).join(": "); 116 | const baseMessage = `Antigravity token refresh failed (${response.status} ${response.statusText})`; 117 | const message = details ? `${baseMessage} - ${details}` : baseMessage; 118 | console.warn(`[Antigravity OAuth] ${message}`); 119 | 120 | if (code === "invalid_grant") { 121 | console.warn( 122 | "[Antigravity OAuth] Google revoked the stored refresh token for this account. Reauthenticate it via `opencode auth login`.", 123 | ); 124 | invalidateProjectContextCache(auth.refresh); 125 | clearCachedAuth(auth.refresh); 126 | } 127 | 128 | throw new AntigravityTokenRefreshError({ 129 | message, 130 | code, 131 | description: description ?? errorText, 132 | status: response.status, 133 | statusText: response.statusText, 134 | }); 135 | } 136 | 137 | const payload = (await response.json()) as { 138 | access_token: string; 139 | expires_in: number; 140 | refresh_token?: string; 141 | }; 142 | 143 | const refreshedParts: RefreshParts = { 144 | refreshToken: payload.refresh_token ?? parts.refreshToken, 145 | projectId: parts.projectId, 146 | managedProjectId: parts.managedProjectId, 147 | }; 148 | 149 | const updatedAuth: OAuthAuthDetails = { 150 | ...auth, 151 | access: payload.access_token, 152 | expires: Date.now() + payload.expires_in * 1000, 153 | refresh: formatRefreshParts(refreshedParts), 154 | }; 155 | 156 | storeCachedAuth(updatedAuth); 157 | invalidateProjectContextCache(auth.refresh); 158 | 159 | return updatedAuth; 160 | } catch (error) { 161 | if (error instanceof AntigravityTokenRefreshError) { 162 | throw error; 163 | } 164 | console.error("Failed to refresh Antigravity access token due to an unexpected error:", error); 165 | return undefined; 166 | } 167 | } 168 | 169 | -------------------------------------------------------------------------------- /src/plugin/accounts.test.ts: -------------------------------------------------------------------------------- 1 | import { beforeEach, describe, expect, it, vi } from "vitest"; 2 | 3 | import { AccountManager } from "./accounts"; 4 | import type { AccountStorage } from "./storage"; 5 | import type { OAuthAuthDetails } from "./types"; 6 | 7 | describe("AccountManager", () => { 8 | beforeEach(() => { 9 | vi.useRealTimers(); 10 | }); 11 | 12 | it("treats on-disk storage as source of truth, even when empty", () => { 13 | const fallback: OAuthAuthDetails = { 14 | type: "oauth", 15 | refresh: "r1|p1", 16 | access: "access", 17 | expires: 123, 18 | }; 19 | 20 | const stored: AccountStorage = { 21 | version: 1, 22 | accounts: [], 23 | activeIndex: 0, 24 | }; 25 | 26 | const manager = new AccountManager(fallback, stored); 27 | expect(manager.getAccountCount()).toBe(0); 28 | }); 29 | 30 | it("rotates accounts round-robin", () => { 31 | const stored: AccountStorage = { 32 | version: 1, 33 | accounts: [ 34 | { refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 }, 35 | { refreshToken: "r2", projectId: "p2", addedAt: 1, lastUsed: 0 }, 36 | ], 37 | activeIndex: 0, 38 | }; 39 | 40 | const manager = new AccountManager(undefined, stored); 41 | 42 | expect(manager.pickNext()?.parts.refreshToken).toBe("r1"); 43 | expect(manager.pickNext()?.parts.refreshToken).toBe("r2"); 44 | expect(manager.pickNext()?.parts.refreshToken).toBe("r1"); 45 | }); 46 | 47 | it("attaches fallback access tokens only to the matching stored account", () => { 48 | const fallback: OAuthAuthDetails = { 49 | type: "oauth", 50 | refresh: "r2|p2", 51 | access: "access-2", 52 | expires: 123, 53 | }; 54 | 55 | const stored: AccountStorage = { 56 | version: 1, 57 | accounts: [ 58 | { refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 }, 59 | { refreshToken: "r2", projectId: "p2", addedAt: 1, lastUsed: 0 }, 60 | ], 61 | activeIndex: 0, 62 | }; 63 | 64 | const manager = new AccountManager(fallback, stored); 65 | const snapshot = manager.getAccountsSnapshot(); 66 | 67 | expect(snapshot[0]?.access).toBeUndefined(); 68 | expect(snapshot[0]?.expires).toBeUndefined(); 69 | expect(snapshot[1]?.access).toBe("access-2"); 70 | expect(snapshot[1]?.expires).toBe(123); 71 | }); 72 | 73 | it("does not attach fallback access tokens to an unrelated account", () => { 74 | const fallback: OAuthAuthDetails = { 75 | type: "oauth", 76 | refresh: "r3|p3", 77 | access: "access-3", 78 | expires: 456, 79 | }; 80 | 81 | const stored: AccountStorage = { 82 | version: 1, 83 | accounts: [ 84 | { refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 }, 85 | { refreshToken: "r2", projectId: "p2", addedAt: 1, lastUsed: 0 }, 86 | ], 87 | activeIndex: 0, 88 | }; 89 | 90 | const manager = new AccountManager(fallback, stored); 91 | const snapshot = manager.getAccountsSnapshot(); 92 | 93 | expect(snapshot.some((account) => !!account.access)).toBe(false); 94 | expect(snapshot.some((account) => typeof account.expires === "number")).toBe(false); 95 | }); 96 | 97 | it("skips rate-limited accounts", () => { 98 | vi.useFakeTimers(); 99 | vi.setSystemTime(new Date(0)); 100 | 101 | const stored: AccountStorage = { 102 | version: 1, 103 | accounts: [ 104 | { refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 }, 105 | { refreshToken: "r2", projectId: "p2", addedAt: 1, lastUsed: 0 }, 106 | ], 107 | activeIndex: 0, 108 | }; 109 | 110 | const manager = new AccountManager(undefined, stored); 111 | 112 | const first = manager.pickNext(); 113 | expect(first?.parts.refreshToken).toBe("r1"); 114 | 115 | manager.markRateLimited(first!, 60_000); 116 | 117 | const next = manager.pickNext(); 118 | expect(next?.parts.refreshToken).toBe("r2"); 119 | }); 120 | 121 | it("returns minimum wait time and re-enables after cooldown", () => { 122 | vi.useFakeTimers(); 123 | vi.setSystemTime(new Date(0)); 124 | 125 | const stored: AccountStorage = { 126 | version: 1, 127 | accounts: [ 128 | { refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 }, 129 | { refreshToken: "r2", projectId: "p2", addedAt: 1, lastUsed: 0 }, 130 | ], 131 | activeIndex: 0, 132 | }; 133 | 134 | const manager = new AccountManager(undefined, stored); 135 | 136 | const acc1 = manager.pickNext()!; 137 | const acc2 = manager.pickNext()!; 138 | 139 | manager.markRateLimited(acc1, 10_000); 140 | manager.markRateLimited(acc2, 5_000); 141 | 142 | expect(manager.pickNext()).toBeNull(); 143 | expect(manager.getMinWaitTimeMs()).toBe(5_000); 144 | 145 | vi.setSystemTime(new Date(6_000)); 146 | 147 | const available = manager.pickNext(); 148 | expect(available?.parts.refreshToken).toBe("r2"); 149 | }); 150 | 151 | it("removes an account and keeps cursor consistent", () => { 152 | vi.useFakeTimers(); 153 | vi.setSystemTime(new Date(0)); 154 | 155 | const stored: AccountStorage = { 156 | version: 1, 157 | accounts: [ 158 | { refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 }, 159 | { refreshToken: "r2", projectId: "p2", addedAt: 1, lastUsed: 0 }, 160 | { refreshToken: "r3", projectId: "p3", addedAt: 1, lastUsed: 0 }, 161 | ], 162 | activeIndex: 1, 163 | }; 164 | 165 | const manager = new AccountManager(undefined, stored); 166 | 167 | const picked = manager.pickNext(); 168 | expect(picked?.parts.refreshToken).toBe("r2"); 169 | 170 | manager.removeAccount(picked!); 171 | expect(manager.getAccountCount()).toBe(2); 172 | 173 | const next = manager.pickNext(); 174 | expect(next?.parts.refreshToken).toBe("r3"); 175 | }); 176 | }); 177 | -------------------------------------------------------------------------------- /src/plugin/debug.ts: -------------------------------------------------------------------------------- 1 | import { createWriteStream } from "node:fs"; 2 | import { join } from "node:path"; 3 | import { cwd, env } from "node:process"; 4 | 5 | const DEBUG_FLAG = env.OPENCODE_ANTIGRAVITY_DEBUG ?? ""; 6 | const MAX_BODY_PREVIEW_CHARS = 12000; 7 | const debugEnabled = DEBUG_FLAG.trim() === "1"; 8 | const logFilePath = debugEnabled ? defaultLogFilePath() : undefined; 9 | const logWriter = createLogWriter(logFilePath); 10 | 11 | export interface AntigravityDebugContext { 12 | id: string; 13 | streaming: boolean; 14 | startedAt: number; 15 | } 16 | 17 | interface AntigravityDebugRequestMeta { 18 | originalUrl: string; 19 | resolvedUrl: string; 20 | method?: string; 21 | headers?: HeadersInit; 22 | body?: BodyInit | null; 23 | streaming: boolean; 24 | projectId?: string; 25 | } 26 | 27 | interface AntigravityDebugResponseMeta { 28 | body?: string; 29 | note?: string; 30 | error?: unknown; 31 | headersOverride?: HeadersInit; 32 | } 33 | 34 | let requestCounter = 0; 35 | 36 | /** 37 | * Begins a debug trace for an Antigravity request, logging request metadata when debugging is enabled. 38 | */ 39 | export function startAntigravityDebugRequest(meta: AntigravityDebugRequestMeta): AntigravityDebugContext | null { 40 | if (!debugEnabled) { 41 | return null; 42 | } 43 | 44 | const id = `ANTIGRAVITY-${++requestCounter}`; 45 | const method = meta.method ?? "GET"; 46 | logDebug(`[Antigravity Debug ${id}] ${method} ${meta.resolvedUrl}`); 47 | if (meta.originalUrl && meta.originalUrl !== meta.resolvedUrl) { 48 | logDebug(`[Antigravity Debug ${id}] Original URL: ${meta.originalUrl}`); 49 | } 50 | if (meta.projectId) { 51 | logDebug(`[Antigravity Debug ${id}] Project: ${meta.projectId}`); 52 | } 53 | logDebug(`[Antigravity Debug ${id}] Streaming: ${meta.streaming ? "yes" : "no"}`); 54 | logDebug(`[Antigravity Debug ${id}] Headers: ${JSON.stringify(maskHeaders(meta.headers))}`); 55 | const bodyPreview = formatBodyPreview(meta.body); 56 | if (bodyPreview) { 57 | logDebug(`[Antigravity Debug ${id}] Body Preview: ${bodyPreview}`); 58 | } 59 | 60 | return { id, streaming: meta.streaming, startedAt: Date.now() }; 61 | } 62 | 63 | /** 64 | * Logs response details for a previously started debug trace when debugging is enabled. 65 | */ 66 | export function logAntigravityDebugResponse( 67 | context: AntigravityDebugContext | null | undefined, 68 | response: Response, 69 | meta: AntigravityDebugResponseMeta = {}, 70 | ): void { 71 | if (!debugEnabled || !context) { 72 | return; 73 | } 74 | 75 | const durationMs = Date.now() - context.startedAt; 76 | logDebug( 77 | `[Antigravity Debug ${context.id}] Response ${response.status} ${response.statusText} (${durationMs}ms)`, 78 | ); 79 | logDebug( 80 | `[Antigravity Debug ${context.id}] Response Headers: ${JSON.stringify( 81 | maskHeaders(meta.headersOverride ?? response.headers), 82 | )}`, 83 | ); 84 | 85 | if (meta.note) { 86 | logDebug(`[Antigravity Debug ${context.id}] Note: ${meta.note}`); 87 | } 88 | 89 | if (meta.error) { 90 | logDebug(`[Antigravity Debug ${context.id}] Error: ${formatError(meta.error)}`); 91 | } 92 | 93 | if (meta.body) { 94 | logDebug( 95 | `[Antigravity Debug ${context.id}] Response Body Preview: ${truncateForLog(meta.body)}`, 96 | ); 97 | } 98 | } 99 | 100 | /** 101 | * Obscures sensitive headers and returns a plain object for logging. 102 | */ 103 | function maskHeaders(headers?: HeadersInit | Headers): Record { 104 | if (!headers) { 105 | return {}; 106 | } 107 | 108 | const result: Record = {}; 109 | const parsed = headers instanceof Headers ? headers : new Headers(headers); 110 | parsed.forEach((value, key) => { 111 | if (key.toLowerCase() === "authorization") { 112 | result[key] = "[redacted]"; 113 | } else { 114 | result[key] = value; 115 | } 116 | }); 117 | return result; 118 | } 119 | 120 | /** 121 | * Produces a short, type-aware preview of a request/response body for logs. 122 | */ 123 | function formatBodyPreview(body?: BodyInit | null): string | undefined { 124 | if (body == null) { 125 | return undefined; 126 | } 127 | 128 | if (typeof body === "string") { 129 | return truncateForLog(body); 130 | } 131 | 132 | if (body instanceof URLSearchParams) { 133 | return truncateForLog(body.toString()); 134 | } 135 | 136 | if (typeof Blob !== "undefined" && body instanceof Blob) { 137 | return `[Blob size=${body.size}]`; 138 | } 139 | 140 | if (typeof FormData !== "undefined" && body instanceof FormData) { 141 | return "[FormData payload omitted]"; 142 | } 143 | 144 | return `[${body.constructor?.name ?? typeof body} payload omitted]`; 145 | } 146 | 147 | /** 148 | * Truncates long strings to a fixed preview length for logging. 149 | */ 150 | function truncateForLog(text: string): string { 151 | if (text.length <= MAX_BODY_PREVIEW_CHARS) { 152 | return text; 153 | } 154 | return `${text.slice(0, MAX_BODY_PREVIEW_CHARS)}... (truncated ${text.length - MAX_BODY_PREVIEW_CHARS} chars)`; 155 | } 156 | 157 | /** 158 | * Writes a single debug line using the configured writer. 159 | */ 160 | function logDebug(line: string): void { 161 | logWriter(line); 162 | } 163 | 164 | /** 165 | * Converts unknown error-like values into printable strings. 166 | */ 167 | function formatError(error: unknown): string { 168 | if (error instanceof Error) { 169 | return error.stack ?? error.message; 170 | } 171 | try { 172 | return JSON.stringify(error); 173 | } catch { 174 | return String(error); 175 | } 176 | } 177 | 178 | /** 179 | * Builds a timestamped log file path in the current working directory. 180 | */ 181 | function defaultLogFilePath(): string { 182 | const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); 183 | return join(cwd(), `antigravity-debug-${timestamp}.log`); 184 | } 185 | 186 | /** 187 | * Creates a line writer that appends to a file when provided. 188 | */ 189 | function createLogWriter(filePath?: string): (line: string) => void { 190 | if (!filePath) { 191 | return () => {}; 192 | } 193 | 194 | const stream = createWriteStream(filePath, { flags: "a" }); 195 | return (line: string) => { 196 | stream.write(`${line}\n`); 197 | }; 198 | } 199 | -------------------------------------------------------------------------------- /src/plugin/auth.test.ts: -------------------------------------------------------------------------------- 1 | import { beforeEach, describe, expect, it, vi } from "vitest"; 2 | 3 | import { isOAuthAuth, parseRefreshParts, formatRefreshParts, accessTokenExpired } from "./auth"; 4 | import type { OAuthAuthDetails, ApiKeyAuthDetails } from "./types"; 5 | 6 | describe("isOAuthAuth", () => { 7 | it("returns true for oauth auth type", () => { 8 | const auth: OAuthAuthDetails = { 9 | type: "oauth", 10 | refresh: "token|project", 11 | access: "access-token", 12 | expires: Date.now() + 3600000, 13 | }; 14 | expect(isOAuthAuth(auth)).toBe(true); 15 | }); 16 | 17 | it("returns false for api_key auth type", () => { 18 | const auth: ApiKeyAuthDetails = { 19 | type: "api_key", 20 | key: "some-api-key", 21 | }; 22 | expect(isOAuthAuth(auth)).toBe(false); 23 | }); 24 | }); 25 | 26 | describe("parseRefreshParts", () => { 27 | it("parses refresh token with all parts", () => { 28 | const result = parseRefreshParts("refreshToken|projectId|managedProjectId"); 29 | expect(result).toEqual({ 30 | refreshToken: "refreshToken", 31 | projectId: "projectId", 32 | managedProjectId: "managedProjectId", 33 | }); 34 | }); 35 | 36 | it("parses refresh token with only refresh and project", () => { 37 | const result = parseRefreshParts("refreshToken|projectId"); 38 | expect(result).toEqual({ 39 | refreshToken: "refreshToken", 40 | projectId: "projectId", 41 | managedProjectId: undefined, 42 | }); 43 | }); 44 | 45 | it("parses refresh token with only refresh token", () => { 46 | const result = parseRefreshParts("refreshToken"); 47 | expect(result).toEqual({ 48 | refreshToken: "refreshToken", 49 | projectId: undefined, 50 | managedProjectId: undefined, 51 | }); 52 | }); 53 | 54 | it("handles empty string", () => { 55 | const result = parseRefreshParts(""); 56 | expect(result).toEqual({ 57 | refreshToken: "", 58 | projectId: undefined, 59 | managedProjectId: undefined, 60 | }); 61 | }); 62 | 63 | it("handles empty parts", () => { 64 | const result = parseRefreshParts("refreshToken||managedProjectId"); 65 | expect(result).toEqual({ 66 | refreshToken: "refreshToken", 67 | projectId: undefined, 68 | managedProjectId: "managedProjectId", 69 | }); 70 | }); 71 | 72 | it("handles undefined/null-like input", () => { 73 | // @ts-expect-error - testing edge case 74 | const result = parseRefreshParts(undefined); 75 | expect(result).toEqual({ 76 | refreshToken: "", 77 | projectId: undefined, 78 | managedProjectId: undefined, 79 | }); 80 | }); 81 | }); 82 | 83 | describe("formatRefreshParts", () => { 84 | it("formats all parts", () => { 85 | const result = formatRefreshParts({ 86 | refreshToken: "refreshToken", 87 | projectId: "projectId", 88 | managedProjectId: "managedProjectId", 89 | }); 90 | expect(result).toBe("refreshToken|projectId|managedProjectId"); 91 | }); 92 | 93 | it("formats without managed project id", () => { 94 | const result = formatRefreshParts({ 95 | refreshToken: "refreshToken", 96 | projectId: "projectId", 97 | }); 98 | expect(result).toBe("refreshToken|projectId"); 99 | }); 100 | 101 | it("formats without project id but with managed project id", () => { 102 | const result = formatRefreshParts({ 103 | refreshToken: "refreshToken", 104 | managedProjectId: "managedProjectId", 105 | }); 106 | expect(result).toBe("refreshToken||managedProjectId"); 107 | }); 108 | 109 | it("formats with only refresh token", () => { 110 | const result = formatRefreshParts({ 111 | refreshToken: "refreshToken", 112 | }); 113 | expect(result).toBe("refreshToken|"); 114 | }); 115 | 116 | it("round-trips correctly with parseRefreshParts", () => { 117 | const original = { 118 | refreshToken: "rt123", 119 | projectId: "proj456", 120 | managedProjectId: "managed789", 121 | }; 122 | const formatted = formatRefreshParts(original); 123 | const parsed = parseRefreshParts(formatted); 124 | expect(parsed).toEqual(original); 125 | }); 126 | }); 127 | 128 | describe("accessTokenExpired", () => { 129 | beforeEach(() => { 130 | vi.useRealTimers(); 131 | }); 132 | 133 | it("returns true when access token is missing", () => { 134 | const auth: OAuthAuthDetails = { 135 | type: "oauth", 136 | refresh: "token", 137 | access: undefined, 138 | expires: Date.now() + 3600000, 139 | }; 140 | expect(accessTokenExpired(auth)).toBe(true); 141 | }); 142 | 143 | it("returns true when expires is missing", () => { 144 | const auth: OAuthAuthDetails = { 145 | type: "oauth", 146 | refresh: "token", 147 | access: "access-token", 148 | expires: undefined, 149 | }; 150 | expect(accessTokenExpired(auth)).toBe(true); 151 | }); 152 | 153 | it("returns true when token is expired", () => { 154 | const auth: OAuthAuthDetails = { 155 | type: "oauth", 156 | refresh: "token", 157 | access: "access-token", 158 | expires: Date.now() - 1000, // expired 1 second ago 159 | }; 160 | expect(accessTokenExpired(auth)).toBe(true); 161 | }); 162 | 163 | it("returns true when token expires within buffer period (60 seconds)", () => { 164 | const auth: OAuthAuthDetails = { 165 | type: "oauth", 166 | refresh: "token", 167 | access: "access-token", 168 | expires: Date.now() + 30000, // expires in 30 seconds (within 60s buffer) 169 | }; 170 | expect(accessTokenExpired(auth)).toBe(true); 171 | }); 172 | 173 | it("returns false when token is valid and outside buffer period", () => { 174 | const auth: OAuthAuthDetails = { 175 | type: "oauth", 176 | refresh: "token", 177 | access: "access-token", 178 | expires: Date.now() + 120000, // expires in 2 minutes 179 | }; 180 | expect(accessTokenExpired(auth)).toBe(false); 181 | }); 182 | 183 | it("returns false when token expires exactly at buffer boundary", () => { 184 | vi.useFakeTimers(); 185 | vi.setSystemTime(new Date(0)); 186 | 187 | const auth: OAuthAuthDetails = { 188 | type: "oauth", 189 | refresh: "token", 190 | access: "access-token", 191 | expires: 60001, // expires 60001ms from now, just outside 60s buffer 192 | }; 193 | expect(accessTokenExpired(auth)).toBe(false); 194 | }); 195 | }); 196 | -------------------------------------------------------------------------------- /src/plugin/accounts.ts: -------------------------------------------------------------------------------- 1 | import { formatRefreshParts, parseRefreshParts } from "./auth"; 2 | import { loadAccounts, saveAccounts, type AccountStorage } from "./storage"; 3 | import type { OAuthAuthDetails, RefreshParts } from "./types"; 4 | 5 | export interface ManagedAccount { 6 | index: number; 7 | email?: string; 8 | addedAt: number; 9 | lastUsed: number; 10 | parts: RefreshParts; 11 | access?: string; 12 | expires?: number; 13 | isRateLimited: boolean; 14 | rateLimitResetTime: number; 15 | } 16 | 17 | function nowMs(): number { 18 | return Date.now(); 19 | } 20 | 21 | function clampNonNegativeInt(value: unknown, fallback: number): number { 22 | if (typeof value !== "number" || !Number.isFinite(value)) { 23 | return fallback; 24 | } 25 | return value < 0 ? 0 : Math.floor(value); 26 | } 27 | 28 | /** 29 | * In-memory multi-account manager for round-robin routing. 30 | * 31 | * Source of truth for the pool is `antigravity-accounts.json`. 32 | */ 33 | export class AccountManager { 34 | private accounts: ManagedAccount[] = []; 35 | private cursor = 0; 36 | 37 | static async loadFromDisk(authFallback?: OAuthAuthDetails): Promise { 38 | const stored = await loadAccounts(); 39 | return new AccountManager(authFallback, stored); 40 | } 41 | 42 | constructor(authFallback?: OAuthAuthDetails, stored?: AccountStorage | null) { 43 | const authParts = authFallback ? parseRefreshParts(authFallback.refresh) : null; 44 | 45 | if (stored && stored.accounts.length === 0) { 46 | this.accounts = []; 47 | this.cursor = 0; 48 | return; 49 | } 50 | 51 | if (stored && stored.accounts.length > 0) { 52 | const baseNow = nowMs(); 53 | this.accounts = stored.accounts 54 | .map((acc, index): ManagedAccount | null => { 55 | if (!acc.refreshToken || typeof acc.refreshToken !== "string") { 56 | return null; 57 | } 58 | const matchesFallback = !!( 59 | authFallback && 60 | authParts && 61 | authParts.refreshToken && 62 | acc.refreshToken === authParts.refreshToken 63 | ); 64 | 65 | return { 66 | index, 67 | email: acc.email, 68 | addedAt: clampNonNegativeInt(acc.addedAt, baseNow), 69 | lastUsed: clampNonNegativeInt(acc.lastUsed, 0), 70 | parts: { 71 | refreshToken: acc.refreshToken, 72 | projectId: acc.projectId, 73 | managedProjectId: acc.managedProjectId, 74 | }, 75 | access: matchesFallback ? authFallback?.access : undefined, 76 | expires: matchesFallback ? authFallback?.expires : undefined, 77 | isRateLimited: !!acc.isRateLimited, 78 | rateLimitResetTime: clampNonNegativeInt(acc.rateLimitResetTime, 0), 79 | }; 80 | }) 81 | .filter((a): a is ManagedAccount => a !== null); 82 | 83 | this.cursor = clampNonNegativeInt(stored.activeIndex, 0); 84 | if (this.accounts.length > 0) { 85 | this.cursor = this.cursor % this.accounts.length; 86 | } 87 | 88 | return; 89 | } 90 | 91 | if (authFallback) { 92 | const parts = parseRefreshParts(authFallback.refresh); 93 | if (parts.refreshToken) { 94 | const now = nowMs(); 95 | this.accounts = [ 96 | { 97 | index: 0, 98 | email: undefined, 99 | addedAt: now, 100 | lastUsed: 0, 101 | parts, 102 | access: authFallback.access, 103 | expires: authFallback.expires, 104 | isRateLimited: false, 105 | rateLimitResetTime: 0, 106 | }, 107 | ]; 108 | this.cursor = 0; 109 | } 110 | } 111 | } 112 | 113 | getAccountCount(): number { 114 | return this.accounts.length; 115 | } 116 | 117 | getAccountsSnapshot(): ManagedAccount[] { 118 | return this.accounts.map((a) => ({ ...a, parts: { ...a.parts } })); 119 | } 120 | 121 | /** 122 | * Picks the next available account (round-robin), skipping accounts in cooldown. 123 | */ 124 | pickNext(): ManagedAccount | null { 125 | const total = this.accounts.length; 126 | if (total === 0) { 127 | return null; 128 | } 129 | 130 | const now = nowMs(); 131 | 132 | // Clear expired cooldowns. 133 | for (const acc of this.accounts) { 134 | if (acc.isRateLimited && acc.rateLimitResetTime > 0 && now > acc.rateLimitResetTime) { 135 | acc.isRateLimited = false; 136 | acc.rateLimitResetTime = 0; 137 | } 138 | } 139 | 140 | for (let i = 0; i < total; i++) { 141 | const idx = (this.cursor + i) % total; 142 | const candidate = this.accounts[idx]; 143 | if (!candidate) { 144 | continue; 145 | } 146 | if (candidate.isRateLimited) { 147 | continue; 148 | } 149 | this.cursor = (idx + 1) % total; 150 | candidate.lastUsed = now; 151 | return candidate; 152 | } 153 | 154 | return null; 155 | } 156 | 157 | markRateLimited(account: ManagedAccount, retryAfterMs: number): void { 158 | const duration = clampNonNegativeInt(retryAfterMs, 0); 159 | account.isRateLimited = true; 160 | account.rateLimitResetTime = nowMs() + duration; 161 | } 162 | 163 | removeAccount(account: ManagedAccount): boolean { 164 | const idx = this.accounts.indexOf(account); 165 | if (idx < 0) { 166 | return false; 167 | } 168 | 169 | this.accounts.splice(idx, 1); 170 | this.accounts.forEach((acc, index) => { 171 | acc.index = index; 172 | }); 173 | 174 | if (this.accounts.length === 0) { 175 | this.cursor = 0; 176 | return true; 177 | } 178 | 179 | if (this.cursor > idx) { 180 | this.cursor -= 1; 181 | } 182 | this.cursor = this.cursor % this.accounts.length; 183 | 184 | return true; 185 | } 186 | 187 | updateFromAuth(account: ManagedAccount, auth: OAuthAuthDetails): void { 188 | const parts = parseRefreshParts(auth.refresh); 189 | account.parts = parts; 190 | account.access = auth.access; 191 | account.expires = auth.expires; 192 | } 193 | 194 | toAuthDetails(account: ManagedAccount): OAuthAuthDetails { 195 | return { 196 | type: "oauth", 197 | refresh: formatRefreshParts(account.parts), 198 | access: account.access, 199 | expires: account.expires, 200 | }; 201 | } 202 | 203 | getMinWaitTimeMs(): number { 204 | const now = nowMs(); 205 | 206 | // Clear expired cooldowns first (same logic as pickNext) 207 | for (const acc of this.accounts) { 208 | if (acc.isRateLimited && acc.rateLimitResetTime > 0 && now > acc.rateLimitResetTime) { 209 | acc.isRateLimited = false; 210 | acc.rateLimitResetTime = 0; 211 | } 212 | } 213 | 214 | const available = this.accounts.some((a) => !a.isRateLimited); 215 | if (available) { 216 | return 0; 217 | } 218 | 219 | const waits = this.accounts 220 | .filter((a) => a.isRateLimited && a.rateLimitResetTime > 0) 221 | .map((a) => Math.max(0, a.rateLimitResetTime - now)); 222 | 223 | return waits.length > 0 ? Math.min(...waits) : 0; 224 | } 225 | 226 | async saveToDisk(): Promise { 227 | const storage: AccountStorage = { 228 | version: 1, 229 | accounts: this.accounts.map((a) => ({ 230 | email: a.email, 231 | refreshToken: a.parts.refreshToken, 232 | projectId: a.parts.projectId, 233 | managedProjectId: a.parts.managedProjectId, 234 | addedAt: a.addedAt, 235 | lastUsed: a.lastUsed, 236 | isRateLimited: a.isRateLimited, 237 | rateLimitResetTime: a.rateLimitResetTime, 238 | })), 239 | activeIndex: this.cursor, 240 | }; 241 | 242 | await saveAccounts(storage); 243 | } 244 | } 245 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | workflow_dispatch: 8 | 9 | permissions: 10 | contents: write 11 | id-token: write 12 | 13 | jobs: 14 | publish: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - name: Checkout 18 | uses: actions/checkout@v4 19 | with: 20 | fetch-depth: 0 21 | 22 | - name: Use Node.js 23 | uses: actions/setup-node@v4 24 | with: 25 | node-version: 20 26 | registry-url: https://registry.npmjs.org 27 | always-auth: true 28 | 29 | - name: Determine release state 30 | id: determine 31 | run: | 32 | set -euo pipefail 33 | CURRENT_VERSION=$(node -p "require('./package.json').version") 34 | echo "current_version=$CURRENT_VERSION" >> "$GITHUB_OUTPUT" 35 | if git rev-parse HEAD^ >/dev/null 2>&1; then 36 | 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) {}") 37 | PREVIOUS_VERSION=${PREVIOUS_VERSION//$'\n'/} 38 | else 39 | PREVIOUS_VERSION="" 40 | fi 41 | echo "previous_version=$PREVIOUS_VERSION" >> "$GITHUB_OUTPUT" 42 | if [ "$CURRENT_VERSION" = "$PREVIOUS_VERSION" ]; then 43 | echo "changed=false" >> "$GITHUB_OUTPUT" 44 | else 45 | echo "changed=true" >> "$GITHUB_OUTPUT" 46 | fi 47 | git fetch --tags --force 48 | if git tag -l "v$CURRENT_VERSION" | grep -q "v$CURRENT_VERSION"; then 49 | echo "tag_exists=true" >> "$GITHUB_OUTPUT" 50 | else 51 | echo "tag_exists=false" >> "$GITHUB_OUTPUT" 52 | fi 53 | 54 | - name: Verify NPM token 55 | if: steps.determine.outputs.changed == 'true' && steps.determine.outputs.tag_exists == 'false' 56 | run: | 57 | if [ -z "${NPM_TOKEN}" ]; then 58 | echo "NPM_TOKEN secret is required" >&2 59 | exit 1 60 | fi 61 | env: 62 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 63 | 64 | - name: Install dependencies 65 | if: steps.determine.outputs.changed == 'true' && steps.determine.outputs.tag_exists == 'false' 66 | run: npm install 67 | 68 | - name: Run Tests 69 | if: steps.determine.outputs.changed == 'true' && steps.determine.outputs.tag_exists == 'false' 70 | run: npm test 71 | 72 | - name: Update README and Tag 73 | if: steps.determine.outputs.changed == 'true' && steps.determine.outputs.tag_exists == 'false' 74 | id: update_readme 75 | run: | 76 | CURRENT_VERSION="${{ steps.determine.outputs.current_version }}" 77 | 78 | git checkout main 79 | git pull origin main 80 | 81 | # Update version in README.md (looking for pattern opencode-antigravity-auth@x.y.z) 82 | sed -i "s/opencode-antigravity-auth@[0-9]\+\.[0-9]\+\.[0-9]\+/opencode-antigravity-auth@$CURRENT_VERSION/g" README.md 83 | 84 | if ! git diff --quiet README.md; then 85 | git config user.name "github-actions[bot]" 86 | git config user.email "github-actions[bot]@users.noreply.github.com" 87 | git add README.md 88 | git commit -m "docs: update readme version to $CURRENT_VERSION [skip ci]" 89 | git push origin main 90 | echo "README updated" 91 | else 92 | echo "README already up to date" 93 | fi 94 | 95 | # Create and push tag on the current HEAD 96 | git tag "v$CURRENT_VERSION" 97 | git push origin "v$CURRENT_VERSION" 98 | 99 | - name: Build 100 | if: steps.determine.outputs.changed == 'true' && steps.determine.outputs.tag_exists == 'false' 101 | run: npm run build 102 | 103 | - name: Verify build artifacts 104 | if: steps.determine.outputs.changed == 'true' && steps.determine.outputs.tag_exists == 'false' 105 | run: | 106 | set -euo pipefail 107 | [ -f dist/index.js ] || { echo "dist/index.js missing" >&2; exit 1; } 108 | [ -f dist/index.d.ts ] || { echo "dist/index.d.ts missing" >&2; exit 1; } 109 | [ -d dist/src ] || { echo "dist/src/ missing" >&2; exit 1; } 110 | 111 | - name: Generate release notes 112 | if: steps.determine.outputs.changed == 'true' && steps.determine.outputs.tag_exists == 'false' 113 | id: release_notes 114 | run: | 115 | set -euo pipefail 116 | CURRENT_VERSION="${{ steps.determine.outputs.current_version }}" 117 | PREVIOUS_VERSION="${{ steps.determine.outputs.previous_version }}" 118 | RANGE="" 119 | COMPARE_URL="" 120 | LAST_TAG=$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || true) 121 | if [ -z "$LAST_TAG" ] && [ -n "$PREVIOUS_VERSION" ] && git rev-parse "refs/tags/v${PREVIOUS_VERSION}" >/dev/null 2>&1; then 122 | LAST_TAG="v${PREVIOUS_VERSION}" 123 | fi 124 | if [ -n "$LAST_TAG" ]; then 125 | RANGE="${LAST_TAG}..HEAD" 126 | COMPARE_URL="https://github.com/${GITHUB_REPOSITORY}/compare/${LAST_TAG}...v${CURRENT_VERSION}" 127 | fi 128 | if [ -n "$RANGE" ]; then 129 | CHANGELOG=$(git log --no-merges --pretty=format:'- %s (%h)' "$RANGE") 130 | else 131 | CHANGELOG=$(git log --no-merges --pretty=format:'- %s (%h)') 132 | fi 133 | if [ -z "$CHANGELOG" ]; then 134 | CHANGELOG="- No commits found for this release." 135 | fi 136 | BODY_FILE=$(mktemp) 137 | { 138 | echo "## Release v${CURRENT_VERSION}" 139 | echo "" 140 | if [ -n "$COMPARE_URL" ]; then 141 | echo "Compare changes: $COMPARE_URL" 142 | echo "" 143 | fi 144 | printf "%s\n" "$CHANGELOG" 145 | echo "" 146 | echo "### Upgrade" 147 | echo "" 148 | echo "Update your \`opencode.json\`:" 149 | echo "" 150 | printf '%s\n' '```json' 151 | printf '%s\n' '{' 152 | printf '%s\n' " \"plugins\": [\"opencode-antigravity-auth@${CURRENT_VERSION}\"]" 153 | printf '%s\n' '}' 154 | printf '%s\n' '```' 155 | echo "" 156 | echo "If stuck on an old version, clear the cache:" 157 | echo "" 158 | printf '%s\n' '```bash' 159 | printf '%s\n' 'rm -rf ~/.cache/opencode/node_modules ~/.cache/opencode/bun.lock' 160 | printf '%s\n' '```' 161 | 162 | } >"$BODY_FILE" 163 | cat "$BODY_FILE" 164 | { 165 | echo "body<>"$GITHUB_OUTPUT" 169 | 170 | - name: Create GitHub release 171 | if: steps.determine.outputs.changed == 'true' && steps.determine.outputs.tag_exists == 'false' 172 | uses: actions/create-release@v1 173 | env: 174 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 175 | with: 176 | tag_name: v${{ steps.determine.outputs.current_version }} 177 | release_name: v${{ steps.determine.outputs.current_version }} 178 | body: ${{ steps.release_notes.outputs.body }} 179 | generate_release_notes: false 180 | 181 | - name: Publish to npm 182 | if: steps.determine.outputs.changed == 'true' && steps.determine.outputs.tag_exists == 'false' 183 | env: 184 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 185 | run: npm publish --access public --provenance 186 | -------------------------------------------------------------------------------- /src/plugin/server.ts: -------------------------------------------------------------------------------- 1 | import { createServer } from "node:http"; 2 | 3 | import { ANTIGRAVITY_REDIRECT_URI } from "../constants"; 4 | 5 | interface OAuthListenerOptions { 6 | /** 7 | * How long to wait for the OAuth redirect before timing out (in milliseconds). 8 | */ 9 | timeoutMs?: number; 10 | } 11 | 12 | export interface OAuthListener { 13 | /** 14 | * Resolves with the callback URL once Google redirects back to the local server. 15 | */ 16 | waitForCallback(): Promise; 17 | /** 18 | * Cleanly stop listening for callbacks. 19 | */ 20 | close(): Promise; 21 | } 22 | 23 | const redirectUri = new URL(ANTIGRAVITY_REDIRECT_URI); 24 | const callbackPath = redirectUri.pathname || "/"; 25 | 26 | /** 27 | * Starts a lightweight HTTP server that listens for the Antigravity OAuth redirect 28 | * and resolves with the captured callback URL. 29 | */ 30 | export async function startOAuthListener( 31 | { timeoutMs = 5 * 60 * 1000 }: OAuthListenerOptions = {}, 32 | ): Promise { 33 | const port = redirectUri.port 34 | ? Number.parseInt(redirectUri.port, 10) 35 | : redirectUri.protocol === "https:" 36 | ? 443 37 | : 80; 38 | const origin = `${redirectUri.protocol}//${redirectUri.host}`; 39 | 40 | let settled = false; 41 | let resolveCallback: (url: URL) => void; 42 | let rejectCallback: (error: Error) => void; 43 | let timeoutHandle: NodeJS.Timeout; 44 | const callbackPromise = new Promise((resolve, reject) => { 45 | resolveCallback = (url: URL) => { 46 | if (settled) return; 47 | settled = true; 48 | if (timeoutHandle) clearTimeout(timeoutHandle); 49 | resolve(url); 50 | }; 51 | rejectCallback = (error: Error) => { 52 | if (settled) return; 53 | settled = true; 54 | if (timeoutHandle) clearTimeout(timeoutHandle); 55 | reject(error); 56 | }; 57 | }); 58 | 59 | const successResponse = ` 60 | 61 | 62 | 63 | 64 | Authentication Successful 65 | 160 | 161 | 162 |
163 |
164 | 165 | 166 | 167 |
168 |

All set!

169 |

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

170 | 171 |
Usage Tip: Most browsers block auto-closing. If the button doesn't work, please close the tab manually.
172 |
173 | 182 | 183 | `; 184 | 185 | timeoutHandle = setTimeout(() => { 186 | rejectCallback(new Error("Timed out waiting for OAuth callback")); 187 | }, timeoutMs); 188 | timeoutHandle.unref?.(); 189 | 190 | const server = createServer((request, response) => { 191 | if (!request.url) { 192 | response.writeHead(400, { "Content-Type": "text/plain" }); 193 | response.end("Invalid request"); 194 | return; 195 | } 196 | 197 | const url = new URL(request.url, origin); 198 | if (url.pathname !== callbackPath) { 199 | response.writeHead(404, { "Content-Type": "text/plain" }); 200 | response.end("Not found"); 201 | return; 202 | } 203 | 204 | response.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }); 205 | response.end(successResponse); 206 | 207 | resolveCallback(url); 208 | 209 | setImmediate(() => { 210 | server.close(); 211 | }); 212 | }); 213 | 214 | await new Promise((resolve, reject) => { 215 | const handleError = (error: Error) => { 216 | server.off("error", handleError); 217 | reject(error); 218 | }; 219 | server.once("error", handleError); 220 | server.listen(port, "127.0.0.1", () => { 221 | server.off("error", handleError); 222 | resolve(); 223 | }); 224 | }); 225 | 226 | server.on("error", (error) => { 227 | rejectCallback(error instanceof Error ? error : new Error(String(error))); 228 | }); 229 | 230 | return { 231 | waitForCallback: () => callbackPromise, 232 | close: () => 233 | new Promise((resolve, reject) => { 234 | server.close((error) => { 235 | if (error && (error as NodeJS.ErrnoException).code !== "ERR_SERVER_NOT_RUNNING") { 236 | reject(error); 237 | return; 238 | } 239 | if (!settled) { 240 | rejectCallback(new Error("OAuth listener closed before callback")); 241 | } 242 | resolve(); 243 | }); 244 | }), 245 | }; 246 | } 247 | 248 | -------------------------------------------------------------------------------- /src/antigravity/oauth.ts: -------------------------------------------------------------------------------- 1 | import { generatePKCE } from "@openauthjs/openauth/pkce"; 2 | 3 | import { 4 | ANTIGRAVITY_CLIENT_ID, 5 | ANTIGRAVITY_CLIENT_SECRET, 6 | ANTIGRAVITY_REDIRECT_URI, 7 | ANTIGRAVITY_SCOPES, 8 | ANTIGRAVITY_ENDPOINT_FALLBACKS, 9 | ANTIGRAVITY_LOAD_ENDPOINTS, 10 | ANTIGRAVITY_HEADERS, 11 | } from "../constants"; 12 | 13 | interface PkcePair { 14 | challenge: string; 15 | verifier: string; 16 | } 17 | 18 | interface AntigravityAuthState { 19 | verifier: string; 20 | projectId: string; 21 | } 22 | 23 | /** 24 | * Result returned to the caller after constructing an OAuth authorization URL. 25 | */ 26 | export interface AntigravityAuthorization { 27 | url: string; 28 | verifier: string; 29 | projectId: string; 30 | } 31 | 32 | interface AntigravityTokenExchangeSuccess { 33 | type: "success"; 34 | refresh: string; 35 | access: string; 36 | expires: number; 37 | email?: string; 38 | projectId: string; 39 | } 40 | 41 | interface AntigravityTokenExchangeFailure { 42 | type: "failed"; 43 | error: string; 44 | } 45 | 46 | export type AntigravityTokenExchangeResult = 47 | | AntigravityTokenExchangeSuccess 48 | | AntigravityTokenExchangeFailure; 49 | 50 | interface AntigravityTokenResponse { 51 | access_token: string; 52 | expires_in: number; 53 | refresh_token: string; 54 | } 55 | 56 | interface AntigravityUserInfo { 57 | email?: string; 58 | } 59 | 60 | /** 61 | * Encode an object into a URL-safe base64 string. 62 | */ 63 | function encodeState(payload: AntigravityAuthState): string { 64 | return Buffer.from(JSON.stringify(payload), "utf8").toString("base64url"); 65 | } 66 | 67 | /** 68 | * Decode an OAuth state parameter back into its structured representation. 69 | */ 70 | function decodeState(state: string): AntigravityAuthState { 71 | const normalized = state.replace(/-/g, "+").replace(/_/g, "/"); 72 | const padded = normalized.padEnd(normalized.length + ((4 - normalized.length % 4) % 4), "="); 73 | const json = Buffer.from(padded, "base64").toString("utf8"); 74 | const parsed = JSON.parse(json); 75 | if (typeof parsed.verifier !== "string") { 76 | throw new Error("Missing PKCE verifier in state"); 77 | } 78 | return { 79 | verifier: parsed.verifier, 80 | projectId: typeof parsed.projectId === "string" ? parsed.projectId : "", 81 | }; 82 | } 83 | 84 | /** 85 | * Build the Antigravity OAuth authorization URL including PKCE and optional project metadata. 86 | */ 87 | export async function authorizeAntigravity(projectId = ""): Promise { 88 | const pkce = (await generatePKCE()) as PkcePair; 89 | 90 | const url = new URL("https://accounts.google.com/o/oauth2/v2/auth"); 91 | url.searchParams.set("client_id", ANTIGRAVITY_CLIENT_ID); 92 | url.searchParams.set("response_type", "code"); 93 | url.searchParams.set("redirect_uri", ANTIGRAVITY_REDIRECT_URI); 94 | url.searchParams.set("scope", ANTIGRAVITY_SCOPES.join(" ")); 95 | url.searchParams.set("code_challenge", pkce.challenge); 96 | url.searchParams.set("code_challenge_method", "S256"); 97 | url.searchParams.set( 98 | "state", 99 | encodeState({ verifier: pkce.verifier, projectId: projectId || "" }), 100 | ); 101 | url.searchParams.set("access_type", "offline"); 102 | url.searchParams.set("prompt", "consent"); 103 | 104 | return { 105 | url: url.toString(), 106 | verifier: pkce.verifier, 107 | projectId: projectId || "", 108 | }; 109 | } 110 | 111 | async function fetchProjectID(accessToken: string): Promise { 112 | const errors: string[] = []; 113 | // Use CLIProxy-aligned headers for project discovery to match "real" Antigravity clients. 114 | const loadHeaders: Record = { 115 | Authorization: `Bearer ${accessToken}`, 116 | "Content-Type": "application/json", 117 | "User-Agent": "google-api-nodejs-client/9.15.1", 118 | "X-Goog-Api-Client": "google-cloud-sdk vscode_cloudshelleditor/0.1", 119 | "Client-Metadata": ANTIGRAVITY_HEADERS["Client-Metadata"], 120 | }; 121 | 122 | const loadEndpoints = Array.from( 123 | new Set([...ANTIGRAVITY_LOAD_ENDPOINTS, ...ANTIGRAVITY_ENDPOINT_FALLBACKS]), 124 | ); 125 | 126 | for (const baseEndpoint of loadEndpoints) { 127 | try { 128 | const url = `${baseEndpoint}/v1internal:loadCodeAssist`; 129 | const response = await fetch(url, { 130 | method: "POST", 131 | headers: loadHeaders, 132 | body: JSON.stringify({ 133 | metadata: { 134 | ideType: "IDE_UNSPECIFIED", 135 | platform: "PLATFORM_UNSPECIFIED", 136 | pluginType: "GEMINI", 137 | }, 138 | }), 139 | }); 140 | 141 | if (!response.ok) { 142 | const message = await response.text().catch(() => ""); 143 | errors.push( 144 | `loadCodeAssist ${response.status} at ${baseEndpoint}${ 145 | message ? `: ${message}` : "" 146 | }`, 147 | ); 148 | continue; 149 | } 150 | 151 | const data = await response.json(); 152 | if (typeof data.cloudaicompanionProject === "string" && data.cloudaicompanionProject) { 153 | return data.cloudaicompanionProject; 154 | } 155 | if ( 156 | data.cloudaicompanionProject && 157 | typeof data.cloudaicompanionProject.id === "string" && 158 | data.cloudaicompanionProject.id 159 | ) { 160 | return data.cloudaicompanionProject.id; 161 | } 162 | 163 | errors.push(`loadCodeAssist missing project id at ${baseEndpoint}`); 164 | } catch (e) { 165 | errors.push( 166 | `loadCodeAssist error at ${baseEndpoint}: ${ 167 | e instanceof Error ? e.message : String(e) 168 | }`, 169 | ); 170 | } 171 | } 172 | 173 | if (errors.length) { 174 | console.warn("Failed to resolve Antigravity project via loadCodeAssist:", errors.join("; ")); 175 | } 176 | return ""; 177 | } 178 | 179 | /** 180 | * Exchange an authorization code for Antigravity CLI access and refresh tokens. 181 | */ 182 | export async function exchangeAntigravity( 183 | code: string, 184 | state: string, 185 | ): Promise { 186 | try { 187 | const { verifier, projectId } = decodeState(state); 188 | 189 | const tokenResponse = await fetch("https://oauth2.googleapis.com/token", { 190 | method: "POST", 191 | headers: { 192 | "Content-Type": "application/x-www-form-urlencoded", 193 | }, 194 | body: new URLSearchParams({ 195 | client_id: ANTIGRAVITY_CLIENT_ID, 196 | client_secret: ANTIGRAVITY_CLIENT_SECRET, 197 | code, 198 | grant_type: "authorization_code", 199 | redirect_uri: ANTIGRAVITY_REDIRECT_URI, 200 | code_verifier: verifier, 201 | }), 202 | }); 203 | 204 | if (!tokenResponse.ok) { 205 | const errorText = await tokenResponse.text(); 206 | return { type: "failed", error: errorText }; 207 | } 208 | 209 | const tokenPayload = (await tokenResponse.json()) as AntigravityTokenResponse; 210 | 211 | const userInfoResponse = await fetch( 212 | "https://www.googleapis.com/oauth2/v1/userinfo?alt=json", 213 | { 214 | headers: { 215 | Authorization: `Bearer ${tokenPayload.access_token}`, 216 | }, 217 | }, 218 | ); 219 | 220 | const userInfo = userInfoResponse.ok 221 | ? ((await userInfoResponse.json()) as AntigravityUserInfo) 222 | : {}; 223 | 224 | const refreshToken = tokenPayload.refresh_token; 225 | if (!refreshToken) { 226 | return { type: "failed", error: "Missing refresh token in response" }; 227 | } 228 | 229 | let effectiveProjectId = projectId; 230 | if (!effectiveProjectId) { 231 | effectiveProjectId = await fetchProjectID(tokenPayload.access_token); 232 | } 233 | 234 | const storedRefresh = `${refreshToken}|${effectiveProjectId || ""}`; 235 | 236 | return { 237 | type: "success", 238 | refresh: storedRefresh, 239 | access: tokenPayload.access_token, 240 | expires: Date.now() + tokenPayload.expires_in * 1000, 241 | email: userInfo.email, 242 | projectId: effectiveProjectId || "", 243 | }; 244 | } catch (error) { 245 | return { 246 | type: "failed", 247 | error: error instanceof Error ? error.message : "Unknown error", 248 | }; 249 | } 250 | } 251 | -------------------------------------------------------------------------------- /src/plugin/project.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ANTIGRAVITY_HEADERS, 3 | ANTIGRAVITY_ENDPOINT_FALLBACKS, 4 | ANTIGRAVITY_LOAD_ENDPOINTS, 5 | ANTIGRAVITY_DEFAULT_PROJECT_ID, 6 | } from "../constants"; 7 | import { formatRefreshParts, parseRefreshParts } from "./auth"; 8 | import type { OAuthAuthDetails, ProjectContextResult } from "./types"; 9 | 10 | const projectContextResultCache = new Map(); 11 | const projectContextPendingCache = new Map>(); 12 | 13 | const CODE_ASSIST_METADATA = { 14 | ideType: "IDE_UNSPECIFIED", 15 | platform: "PLATFORM_UNSPECIFIED", 16 | pluginType: "GEMINI", 17 | } as const; 18 | 19 | interface AntigravityUserTier { 20 | id?: string; 21 | isDefault?: boolean; 22 | userDefinedCloudaicompanionProject?: boolean; 23 | } 24 | 25 | interface LoadCodeAssistPayload { 26 | cloudaicompanionProject?: string | { id?: string }; 27 | currentTier?: { 28 | id?: string; 29 | }; 30 | allowedTiers?: AntigravityUserTier[]; 31 | } 32 | 33 | interface OnboardUserPayload { 34 | done?: boolean; 35 | response?: { 36 | cloudaicompanionProject?: { 37 | id?: string; 38 | }; 39 | }; 40 | } 41 | 42 | class ProjectIdRequiredError extends Error { 43 | /** 44 | * Error raised when a required Google Cloud project is missing during Antigravity onboarding. 45 | */ 46 | constructor() { 47 | super( 48 | "Google Antigravity requires a Google Cloud project. Enable the Antigravity API on a project you control, rerun `opencode auth login`, and supply that project ID when prompted.", 49 | ); 50 | } 51 | } 52 | 53 | /** 54 | * Builds metadata headers required by the Code Assist API. 55 | */ 56 | function buildMetadata(projectId?: string): Record { 57 | const metadata: Record = { 58 | ideType: CODE_ASSIST_METADATA.ideType, 59 | platform: CODE_ASSIST_METADATA.platform, 60 | pluginType: CODE_ASSIST_METADATA.pluginType, 61 | }; 62 | if (projectId) { 63 | metadata.duetProject = projectId; 64 | } 65 | return metadata; 66 | } 67 | 68 | /** 69 | * Selects the default tier ID from the allowed tiers list. 70 | */ 71 | function getDefaultTierId(allowedTiers?: AntigravityUserTier[]): string | undefined { 72 | if (!allowedTiers || allowedTiers.length === 0) { 73 | return undefined; 74 | } 75 | for (const tier of allowedTiers) { 76 | if (tier?.isDefault) { 77 | return tier.id; 78 | } 79 | } 80 | return allowedTiers[0]?.id; 81 | } 82 | 83 | /** 84 | * Promise-based delay utility. 85 | */ 86 | function wait(ms: number): Promise { 87 | return new Promise(function (resolve) { 88 | setTimeout(resolve, ms); 89 | }); 90 | } 91 | 92 | /** 93 | * Extracts the cloudaicompanion project id from loadCodeAssist responses. 94 | */ 95 | function extractManagedProjectId(payload: LoadCodeAssistPayload | null): string | undefined { 96 | if (!payload) { 97 | return undefined; 98 | } 99 | if (typeof payload.cloudaicompanionProject === "string") { 100 | return payload.cloudaicompanionProject; 101 | } 102 | if (payload.cloudaicompanionProject && typeof payload.cloudaicompanionProject.id === "string") { 103 | return payload.cloudaicompanionProject.id; 104 | } 105 | return undefined; 106 | } 107 | 108 | /** 109 | * Generates a cache key for project context based on refresh token. 110 | */ 111 | function getCacheKey(auth: OAuthAuthDetails): string | undefined { 112 | const refresh = auth.refresh?.trim(); 113 | return refresh ? refresh : undefined; 114 | } 115 | 116 | /** 117 | * Clears cached project context results and pending promises, globally or for a refresh key. 118 | */ 119 | export function invalidateProjectContextCache(refresh?: string): void { 120 | if (!refresh) { 121 | projectContextPendingCache.clear(); 122 | projectContextResultCache.clear(); 123 | return; 124 | } 125 | projectContextPendingCache.delete(refresh); 126 | projectContextResultCache.delete(refresh); 127 | } 128 | 129 | /** 130 | * Loads managed project information for the given access token and optional project. 131 | */ 132 | export async function loadManagedProject( 133 | accessToken: string, 134 | projectId?: string, 135 | ): Promise { 136 | const metadata = buildMetadata(projectId); 137 | const requestBody: Record = { metadata }; 138 | 139 | const loadHeaders: Record = { 140 | "Content-Type": "application/json", 141 | Authorization: `Bearer ${accessToken}`, 142 | "User-Agent": "google-api-nodejs-client/9.15.1", 143 | "X-Goog-Api-Client": "google-cloud-sdk vscode_cloudshelleditor/0.1", 144 | "Client-Metadata": ANTIGRAVITY_HEADERS["Client-Metadata"], 145 | }; 146 | 147 | const loadEndpoints = Array.from( 148 | new Set([...ANTIGRAVITY_LOAD_ENDPOINTS, ...ANTIGRAVITY_ENDPOINT_FALLBACKS]), 149 | ); 150 | 151 | for (const baseEndpoint of loadEndpoints) { 152 | try { 153 | const response = await fetch( 154 | `${baseEndpoint}/v1internal:loadCodeAssist`, 155 | { 156 | method: "POST", 157 | headers: loadHeaders, 158 | body: JSON.stringify(requestBody), 159 | }, 160 | ); 161 | 162 | if (!response.ok) { 163 | continue; 164 | } 165 | 166 | return (await response.json()) as LoadCodeAssistPayload; 167 | } catch (error) { 168 | console.error(`Failed to load Antigravity managed project via ${baseEndpoint}:`, error); 169 | continue; 170 | } 171 | } 172 | 173 | return null; 174 | } 175 | 176 | 177 | /** 178 | * Onboards a managed project for the user, optionally retrying until completion. 179 | */ 180 | export async function onboardManagedProject( 181 | accessToken: string, 182 | tierId: string, 183 | projectId?: string, 184 | attempts = 10, 185 | delayMs = 5000, 186 | ): Promise { 187 | const metadata = buildMetadata(projectId); 188 | const requestBody: Record = { 189 | tierId, 190 | metadata, 191 | }; 192 | 193 | if (tierId !== "FREE") { 194 | if (!projectId) { 195 | throw new ProjectIdRequiredError(); 196 | } 197 | requestBody.cloudaicompanionProject = projectId; 198 | } 199 | 200 | for (const baseEndpoint of ANTIGRAVITY_ENDPOINT_FALLBACKS) { 201 | for (let attempt = 0; attempt < attempts; attempt += 1) { 202 | try { 203 | const response = await fetch( 204 | `${baseEndpoint}/v1internal:onboardUser`, 205 | { 206 | method: "POST", 207 | headers: { 208 | "Content-Type": "application/json", 209 | Authorization: `Bearer ${accessToken}`, 210 | ...ANTIGRAVITY_HEADERS, 211 | }, 212 | body: JSON.stringify(requestBody), 213 | }, 214 | ); 215 | 216 | if (!response.ok) { 217 | break; 218 | } 219 | 220 | const payload = (await response.json()) as OnboardUserPayload; 221 | const managedProjectId = payload.response?.cloudaicompanionProject?.id; 222 | if (payload.done && managedProjectId) { 223 | return managedProjectId; 224 | } 225 | if (payload.done && projectId) { 226 | return projectId; 227 | } 228 | } catch (error) { 229 | console.error( 230 | `Failed to onboard Antigravity managed project via ${baseEndpoint}:`, 231 | error, 232 | ); 233 | break; 234 | } 235 | 236 | await wait(delayMs); 237 | } 238 | } 239 | 240 | return undefined; 241 | } 242 | 243 | /** 244 | * Resolves an effective project ID for the current auth state, caching results per refresh token. 245 | */ 246 | export async function ensureProjectContext(auth: OAuthAuthDetails): Promise { 247 | const accessToken = auth.access; 248 | if (!accessToken) { 249 | return { auth, effectiveProjectId: "" }; 250 | } 251 | 252 | const cacheKey = getCacheKey(auth); 253 | if (cacheKey) { 254 | const cached = projectContextResultCache.get(cacheKey); 255 | if (cached) { 256 | return cached; 257 | } 258 | const pending = projectContextPendingCache.get(cacheKey); 259 | if (pending) { 260 | return pending; 261 | } 262 | } 263 | 264 | const resolveContext = async (): Promise => { 265 | const parts = parseRefreshParts(auth.refresh); 266 | if (parts.managedProjectId) { 267 | return { auth, effectiveProjectId: parts.managedProjectId }; 268 | } 269 | 270 | const fallbackProjectId = ANTIGRAVITY_DEFAULT_PROJECT_ID; 271 | const persistManagedProject = async (managedProjectId: string): Promise => { 272 | const updatedAuth: OAuthAuthDetails = { 273 | ...auth, 274 | refresh: formatRefreshParts({ 275 | refreshToken: parts.refreshToken, 276 | projectId: parts.projectId, 277 | managedProjectId, 278 | }), 279 | }; 280 | 281 | return { auth: updatedAuth, effectiveProjectId: managedProjectId }; 282 | }; 283 | 284 | // Try to resolve a managed project from Antigravity if possible. 285 | const loadPayload = await loadManagedProject(accessToken, parts.projectId ?? fallbackProjectId); 286 | const resolvedManagedProjectId = extractManagedProjectId(loadPayload); 287 | 288 | if (resolvedManagedProjectId) { 289 | return persistManagedProject(resolvedManagedProjectId); 290 | } 291 | 292 | if (parts.projectId) { 293 | return { auth, effectiveProjectId: parts.projectId }; 294 | } 295 | 296 | // No project id present in auth; fall back to the hardcoded id for requests. 297 | return { auth, effectiveProjectId: fallbackProjectId }; 298 | }; 299 | 300 | if (!cacheKey) { 301 | return resolveContext(); 302 | } 303 | 304 | const promise = resolveContext() 305 | .then((result) => { 306 | const nextKey = getCacheKey(result.auth) ?? cacheKey; 307 | projectContextPendingCache.delete(cacheKey); 308 | projectContextResultCache.set(nextKey, result); 309 | if (nextKey !== cacheKey) { 310 | projectContextResultCache.delete(cacheKey); 311 | } 312 | return result; 313 | }) 314 | .catch((error) => { 315 | projectContextPendingCache.delete(cacheKey); 316 | throw error; 317 | }); 318 | 319 | projectContextPendingCache.set(cacheKey, promise); 320 | return promise; 321 | } 322 | -------------------------------------------------------------------------------- /src/plugin/cache.test.ts: -------------------------------------------------------------------------------- 1 | import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; 2 | 3 | import { 4 | resolveCachedAuth, 5 | storeCachedAuth, 6 | clearCachedAuth, 7 | cacheSignature, 8 | getCachedSignature, 9 | clearSignatureCache, 10 | } from "./cache"; 11 | import type { OAuthAuthDetails } from "./types"; 12 | 13 | function createAuth(overrides: Partial = {}): OAuthAuthDetails { 14 | return { 15 | type: "oauth", 16 | refresh: "refresh-token|project-id", 17 | access: "access-token", 18 | expires: Date.now() + 3600000, 19 | ...overrides, 20 | }; 21 | } 22 | 23 | describe("Auth Cache", () => { 24 | beforeEach(() => { 25 | vi.useRealTimers(); 26 | clearCachedAuth(); 27 | }); 28 | 29 | afterEach(() => { 30 | clearCachedAuth(); 31 | }); 32 | 33 | describe("resolveCachedAuth", () => { 34 | it("returns input auth when no cache exists and caches it", () => { 35 | const auth = createAuth(); 36 | const result = resolveCachedAuth(auth); 37 | expect(result).toEqual(auth); 38 | }); 39 | 40 | it("returns input auth when refresh key is empty", () => { 41 | const auth = createAuth({ refresh: "" }); 42 | const result = resolveCachedAuth(auth); 43 | expect(result).toEqual(auth); 44 | }); 45 | 46 | it("returns input auth when it has valid (unexpired) access token", () => { 47 | const oldAuth = createAuth({ access: "old-access", expires: Date.now() + 3600000 }); 48 | resolveCachedAuth(oldAuth); // cache it 49 | 50 | const newAuth = createAuth({ access: "new-access", expires: Date.now() + 7200000 }); 51 | const result = resolveCachedAuth(newAuth); 52 | expect(result.access).toBe("new-access"); 53 | }); 54 | 55 | it("returns cached auth when input auth is expired but cached is valid", () => { 56 | vi.useFakeTimers(); 57 | vi.setSystemTime(new Date(0)); 58 | 59 | const validAuth = createAuth({ 60 | access: "valid-access", 61 | expires: 3600000, // expires at t=3600000 62 | }); 63 | resolveCachedAuth(validAuth); // cache it 64 | 65 | // Now create an expired auth with the same refresh token 66 | const expiredAuth = createAuth({ 67 | access: "expired-access", 68 | expires: 30000, // expires within buffer (60s) 69 | }); 70 | 71 | const result = resolveCachedAuth(expiredAuth); 72 | expect(result.access).toBe("valid-access"); 73 | }); 74 | 75 | it("returns input auth when both are expired (updates cache)", () => { 76 | vi.useFakeTimers(); 77 | vi.setSystemTime(new Date(0)); 78 | 79 | const expiredCached = createAuth({ 80 | access: "cached-expired", 81 | expires: 30000, // expired within buffer 82 | }); 83 | resolveCachedAuth(expiredCached); 84 | 85 | const expiredNew = createAuth({ 86 | access: "new-expired", 87 | expires: 20000, // also expired within buffer 88 | }); 89 | 90 | const result = resolveCachedAuth(expiredNew); 91 | expect(result.access).toBe("new-expired"); 92 | }); 93 | }); 94 | 95 | describe("storeCachedAuth", () => { 96 | it("stores auth in cache", () => { 97 | const auth = createAuth({ access: "stored-access" }); 98 | storeCachedAuth(auth); 99 | 100 | const expiredAuth = createAuth({ access: "expired", expires: Date.now() - 1000 }); 101 | const result = resolveCachedAuth(expiredAuth); 102 | expect(result.access).toBe("stored-access"); 103 | }); 104 | 105 | it("does nothing when refresh key is empty", () => { 106 | const auth = createAuth({ refresh: "", access: "no-key-access" }); 107 | storeCachedAuth(auth); 108 | 109 | // Should not be retrievable since key was empty 110 | const testAuth = createAuth({ refresh: "", access: "test" }); 111 | const result = resolveCachedAuth(testAuth); 112 | expect(result.access).toBe("test"); // returns the input, not cached 113 | }); 114 | 115 | it("does nothing when refresh key is whitespace only", () => { 116 | const auth = createAuth({ refresh: " ", access: "whitespace-access" }); 117 | storeCachedAuth(auth); 118 | 119 | const testAuth = createAuth({ refresh: " ", access: "test" }); 120 | const result = resolveCachedAuth(testAuth); 121 | expect(result.access).toBe("test"); 122 | }); 123 | }); 124 | 125 | describe("clearCachedAuth", () => { 126 | it("clears all cache when no argument provided", () => { 127 | storeCachedAuth(createAuth({ refresh: "token1|p", access: "access1" })); 128 | storeCachedAuth(createAuth({ refresh: "token2|p", access: "access2" })); 129 | 130 | clearCachedAuth(); 131 | 132 | const auth1 = createAuth({ refresh: "token1|p", access: "new1" }); 133 | const auth2 = createAuth({ refresh: "token2|p", access: "new2" }); 134 | 135 | expect(resolveCachedAuth(auth1).access).toBe("new1"); 136 | expect(resolveCachedAuth(auth2).access).toBe("new2"); 137 | }); 138 | 139 | it("clears specific refresh token from cache", () => { 140 | storeCachedAuth(createAuth({ refresh: "token1|p", access: "access1" })); 141 | storeCachedAuth(createAuth({ refresh: "token2|p", access: "access2" })); 142 | 143 | clearCachedAuth("token1|p"); 144 | 145 | // token1 should be cleared 146 | const expiredAuth1 = createAuth({ refresh: "token1|p", access: "new1", expires: Date.now() - 1000 }); 147 | expect(resolveCachedAuth(expiredAuth1).access).toBe("new1"); 148 | 149 | // token2 should still be cached 150 | const expiredAuth2 = createAuth({ refresh: "token2|p", access: "new2", expires: Date.now() - 1000 }); 151 | expect(resolveCachedAuth(expiredAuth2).access).toBe("access2"); 152 | }); 153 | }); 154 | }); 155 | 156 | describe("Signature Cache", () => { 157 | beforeEach(() => { 158 | vi.useRealTimers(); 159 | clearSignatureCache(); 160 | }); 161 | 162 | afterEach(() => { 163 | clearSignatureCache(); 164 | }); 165 | 166 | describe("cacheSignature", () => { 167 | it("caches a signature for session and text", () => { 168 | cacheSignature("session1", "thinking text", "sig123"); 169 | const result = getCachedSignature("session1", "thinking text"); 170 | expect(result).toBe("sig123"); 171 | }); 172 | 173 | it("does nothing when sessionId is empty", () => { 174 | cacheSignature("", "text", "sig"); 175 | expect(getCachedSignature("", "text")).toBeUndefined(); 176 | }); 177 | 178 | it("does nothing when text is empty", () => { 179 | cacheSignature("session", "", "sig"); 180 | expect(getCachedSignature("session", "")).toBeUndefined(); 181 | }); 182 | 183 | it("does nothing when signature is empty", () => { 184 | cacheSignature("session", "text", ""); 185 | expect(getCachedSignature("session", "text")).toBeUndefined(); 186 | }); 187 | 188 | it("stores multiple signatures per session", () => { 189 | cacheSignature("session1", "text1", "sig1"); 190 | cacheSignature("session1", "text2", "sig2"); 191 | 192 | expect(getCachedSignature("session1", "text1")).toBe("sig1"); 193 | expect(getCachedSignature("session1", "text2")).toBe("sig2"); 194 | }); 195 | 196 | it("stores signatures for different sessions independently", () => { 197 | cacheSignature("session1", "text", "sig1"); 198 | cacheSignature("session2", "text", "sig2"); 199 | 200 | expect(getCachedSignature("session1", "text")).toBe("sig1"); 201 | expect(getCachedSignature("session2", "text")).toBe("sig2"); 202 | }); 203 | }); 204 | 205 | describe("getCachedSignature", () => { 206 | it("returns undefined when session not found", () => { 207 | expect(getCachedSignature("unknown", "text")).toBeUndefined(); 208 | }); 209 | 210 | it("returns undefined when text not found in session", () => { 211 | cacheSignature("session", "known-text", "sig"); 212 | expect(getCachedSignature("session", "unknown-text")).toBeUndefined(); 213 | }); 214 | 215 | it("returns undefined when sessionId is empty", () => { 216 | expect(getCachedSignature("", "text")).toBeUndefined(); 217 | }); 218 | 219 | it("returns undefined when text is empty", () => { 220 | expect(getCachedSignature("session", "")).toBeUndefined(); 221 | }); 222 | 223 | it("returns undefined when signature is expired", () => { 224 | vi.useFakeTimers(); 225 | vi.setSystemTime(new Date(0)); 226 | 227 | cacheSignature("session", "text", "sig"); 228 | 229 | // Advance time past TTL (1 hour = 3600000ms) 230 | vi.setSystemTime(new Date(3600001)); 231 | 232 | expect(getCachedSignature("session", "text")).toBeUndefined(); 233 | }); 234 | 235 | it("returns signature when not expired", () => { 236 | vi.useFakeTimers(); 237 | vi.setSystemTime(new Date(0)); 238 | 239 | cacheSignature("session", "text", "sig"); 240 | 241 | // Advance time but stay within TTL 242 | vi.setSystemTime(new Date(3599999)); 243 | 244 | expect(getCachedSignature("session", "text")).toBe("sig"); 245 | }); 246 | }); 247 | 248 | describe("clearSignatureCache", () => { 249 | it("clears all signature cache when no argument provided", () => { 250 | cacheSignature("session1", "text", "sig1"); 251 | cacheSignature("session2", "text", "sig2"); 252 | 253 | clearSignatureCache(); 254 | 255 | expect(getCachedSignature("session1", "text")).toBeUndefined(); 256 | expect(getCachedSignature("session2", "text")).toBeUndefined(); 257 | }); 258 | 259 | it("clears specific session from cache", () => { 260 | cacheSignature("session1", "text", "sig1"); 261 | cacheSignature("session2", "text", "sig2"); 262 | 263 | clearSignatureCache("session1"); 264 | 265 | expect(getCachedSignature("session1", "text")).toBeUndefined(); 266 | expect(getCachedSignature("session2", "text")).toBe("sig2"); 267 | }); 268 | }); 269 | 270 | describe("cache eviction", () => { 271 | it("evicts entries when at capacity", () => { 272 | vi.useFakeTimers(); 273 | vi.setSystemTime(new Date(0)); 274 | 275 | // Fill cache with 100 entries (MAX_ENTRIES_PER_SESSION) 276 | for (let i = 0; i < 100; i++) { 277 | vi.setSystemTime(new Date(i * 1000)); // stagger timestamps 278 | cacheSignature("session", `text-${i}`, `sig-${i}`); 279 | } 280 | 281 | // Reset time to check entries 282 | vi.setSystemTime(new Date(100 * 1000)); 283 | 284 | // Adding one more should trigger eviction 285 | cacheSignature("session", "new-text", "new-sig"); 286 | 287 | // New entry should exist 288 | expect(getCachedSignature("session", "new-text")).toBe("new-sig"); 289 | 290 | // Some old entries should have been evicted (oldest 25%) 291 | // Entry at index 0 (timestamp 0) should be evicted 292 | expect(getCachedSignature("session", "text-0")).toBeUndefined(); 293 | }); 294 | }); 295 | }); 296 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Antigravity OAuth Plugin for Opencode 2 | 3 | [![npm version](https://img.shields.io/npm/v/opencode-antigravity-auth.svg)](https://www.npmjs.com/package/opencode-antigravity-auth) 4 | 5 | Enable Opencode to authenticate against **Antigravity** (Google's IDE) via OAuth so you can use Antigravity rate limits and access models like `gemini-3-pro-high` and `claude-opus-4-5-thinking` with your Google credentials. 6 | 7 | ## What you get 8 | 9 | - **Google OAuth sign-in** (multi-account via `opencode auth login`) with automatic token refresh 10 | - **Multi-account load balancing** Automatically cycle through multiple Google accounts to maximize rate limits 11 | - **Real-time SSE streaming** including thinking blocks and incremental output 12 | - **Advanced Claude support** Interleaved thinking, stable multi-turn signatures, and validated tool calling 13 | - **Automatic endpoint fallback** between Antigravity API endpoints (daily → autopush → prod) 14 | - **Antigravity API compatibility** for OpenAI-style requests 15 | - **Debug logging** for requests and responses 16 | - **Drop-in setup** Opencode auto-installs the plugin from config 17 | 18 | ## Quick start 19 | 20 | ### Step 1: Create your config file 21 | 22 | If this is your first time using Opencode, create the config directory first: 23 | 24 | ```bash 25 | mkdir -p ~/.config/opencode 26 | ``` 27 | 28 | Then create or edit the config file at `~/.config/opencode/opencode.json`: 29 | 30 | ```json 31 | { 32 | "plugin": ["opencode-antigravity-auth@1.2.0"] 33 | } 34 | ``` 35 | 36 | > **Note:** You can also use a project-local `.opencode.json` file in your project root instead. The global config at `~/.config/opencode/opencode.json` applies to all projects. 37 | 38 | ### Step 2: Authenticate 39 | 40 | Run the authentication command: 41 | 42 | ```bash 43 | opencode auth login 44 | ``` 45 | 46 | 1. Select **Google** as the provider 47 | 2. Select **OAuth with Google (Antigravity)** 48 | 3. **Project ID prompt:** You'll see this prompt: 49 | ``` 50 | Project ID (leave blank to use your default project): 51 | ``` 52 | **Just press Enter to skip this** — it's optional and only needed if you want to use a specific Google Cloud project. Most users can leave it blank. 53 | 4. Sign in via the browser and return to Opencode. If the browser doesn't open, copy the displayed URL manually. 54 | 5. After signing in, you can add more Google accounts (up to 10) for load balancing, or press Enter to finish. 55 | 56 | > **Alternative:** For a quick single-account setup without project ID options, open `opencode` and use the `/connect` command instead. 57 | 58 | ### Step 3: Add the models you want to use 59 | 60 | Open the **same config file** you created in Step 1 (`~/.config/opencode/opencode.json`) and add the models under `provider.google.models`: 61 | 62 | ```json 63 | { 64 | "plugin": ["opencode-antigravity-auth@1.2.0"], 65 | "provider": { 66 | "google": { 67 | "models": { 68 | "gemini-3-pro-high": { 69 | "name": "Gemini 3 Pro High (Antigravity)", 70 | "limit": { 71 | "context": 1048576, 72 | "output": 65535 73 | }, 74 | "modalities": { 75 | "input": ["text", "image", "pdf"], 76 | "output": ["text"] 77 | } 78 | }, 79 | "gemini-3-pro-low": { 80 | "name": "Gemini 3 Pro Low (Antigravity)", 81 | "limit": { 82 | "context": 1048576, 83 | "output": 65535 84 | }, 85 | "modalities": { 86 | "input": ["text", "image", "pdf"], 87 | "output": ["text"] 88 | } 89 | }, 90 | "gemini-3-flash": { 91 | "name": "Gemini 3 Flash (Antigravity)", 92 | "limit": { 93 | "context": 1048576, 94 | "output": 65536 95 | }, 96 | "modalities": { 97 | "input": ["text", "image", "pdf"], 98 | "output": ["text"] 99 | } 100 | }, 101 | "claude-sonnet-4-5": { 102 | "name": "Claude Sonnet 4.5 (Antigravity)", 103 | "limit": { 104 | "context": 200000, 105 | "output": 64000 106 | }, 107 | "modalities": { 108 | "input": ["text", "image", "pdf"], 109 | "output": ["text"] 110 | } 111 | }, 112 | "claude-sonnet-4-5-thinking": { 113 | "name": "Claude Sonnet 4.5 Thinking (Antigravity)", 114 | "limit": { 115 | "context": 200000, 116 | "output": 64000 117 | }, 118 | "modalities": { 119 | "input": ["text", "image", "pdf"], 120 | "output": ["text"] 121 | } 122 | }, 123 | "claude-opus-4-5-thinking": { 124 | "name": "Claude Opus 4.5 Thinking (Antigravity)", 125 | "limit": { 126 | "context": 200000, 127 | "output": 64000 128 | }, 129 | "modalities": { 130 | "input": ["text", "image", "pdf"], 131 | "output": ["text"] 132 | } 133 | }, 134 | "gpt-oss-120b-medium": { 135 | "name": "GPT-OSS 120B Medium (Antigravity)", 136 | "limit": { 137 | "context": 131072, 138 | "output": 32768 139 | }, 140 | "modalities": { 141 | "input": ["text", "image", "pdf"], 142 | "output": ["text"] 143 | } 144 | } 145 | } 146 | } 147 | } 148 | } 149 | ``` 150 | 151 | > **Tip:** You only need to add the models you plan to use. The example above includes all available models, but you can remove any you don't need. The `modalities` field enables image and PDF support in the TUI. 152 | 153 | ### Step 4: Use a model 154 | 155 | ```bash 156 | opencode run "Hello world" --model=google/gemini-3-pro-high 157 | ``` 158 | 159 | Or start the interactive TUI and select a model from the model picker: 160 | 161 | ```bash 162 | opencode 163 | ``` 164 | 165 | ## Multi-account load balancing 166 | 167 | The plugin supports multiple Google accounts to maximize rate limits and provide automatic failover. 168 | 169 | ### How it works 170 | 171 | - **Round-robin selection:** Each request uses the next account in the pool 172 | - **Automatic failover:** On HTTP `429` (rate limit), the plugin automatically switches to the next available account 173 | - **Smart cooldown:** Rate-limited accounts are temporarily cooled down and automatically become available again after the cooldown expires 174 | - **Single-account retry:** If you only have one account, the plugin waits for the rate limit to reset and retries automatically 175 | - **Toast notifications:** The TUI shows which account is being used and when switching occurs 176 | 177 | ### Adding accounts 178 | 179 | **CLI flow (`opencode auth login`):** 180 | 181 | When you run `opencode auth login` and already have accounts saved, you'll be prompted: 182 | 183 | ``` 184 | 2 account(s) saved: 185 | 1. user1@gmail.com 186 | 2. user2@gmail.com 187 | 188 | (a)dd new account(s) or (f)resh start? [a/f]: 189 | ``` 190 | 191 | - Press `a` to add more accounts to your existing pool 192 | - Press `f` to clear all existing accounts and start fresh 193 | 194 | **TUI flow (`/connect`):** 195 | 196 | The `/connect` command in the TUI adds accounts non-destructively — it will never clear your existing accounts. To start fresh via TUI, run `opencode auth logout` first, then `/connect`. 197 | 198 | ### Account storage 199 | 200 | - Account pool is stored in `~/.config/opencode/antigravity-accounts.json` (or `%APPDATA%\opencode\antigravity-accounts.json` on Windows) 201 | - This file contains OAuth refresh tokens; **treat it like a password** and don't share or commit it 202 | - The plugin automatically syncs with OpenCode's auth state — if you log out via OpenCode, stale account storage is cleared automatically 203 | 204 | ### Automatic account recovery 205 | 206 | - If Google revokes a refresh token (`invalid_grant`), that account is automatically removed from the pool 207 | - Rerun `opencode auth login` to re-add the account 208 | 209 | ## Architecture & Flow 210 | 211 | For contributors and advanced users, see the detailed documentation: 212 | 213 | - **[Claude Model Flow](docs/CLAUDE_MODEL_FLOW.md)** - Full request/response flow, improvements, and fixes 214 | - **[Antigravity API Spec](docs/ANTIGRAVITY_API_SPEC.md)** - API reference and schema support matrix 215 | 216 | ## Streaming & thinking 217 | 218 | This plugin supports **real-time SSE streaming**, meaning you see thinking blocks and text output incrementally as they are generated. 219 | 220 | ### Claude Thinking & Tools 221 | 222 | For models like `claude-opus-4-5-thinking`: 223 | 224 | - **Interleaved Thinking:** The plugin automatically enables `anthropic-beta: interleaved-thinking-2025-05-14`. This allows Claude to think *between* tool calls and after tool results, improving complex reasoning. 225 | - **Smart System Hints:** A system instruction is silently added to encourage the model to "think" before and during tool use. 226 | - **Multi-turn Stability:** Thinking signatures are cached and restored using a stable `sessionId`, preventing "invalid signature" errors in long conversations. 227 | - **Thinking Budget Safety:** If a thinking budget is enabled, the plugin ensures output token limits are high enough to avoid budget-related errors. 228 | - **Tool Use:** Tool calls and responses are assigned proper IDs, and tool calling is set to validated mode for better Claude compatibility. 229 | 230 | **Troubleshooting:** If you see signature errors in multi-turn tool loops, restart `opencode` to reset the plugin session/signature cache. 231 | 232 | ## Debugging 233 | 234 | Enable verbose logging: 235 | 236 | ```bash 237 | export OPENCODE_ANTIGRAVITY_DEBUG=1 238 | ``` 239 | 240 | Logs are written to the current directory (e.g., `antigravity-debug-.log`). 241 | 242 | ## Development 243 | 244 | ```bash 245 | npm install 246 | ``` 247 | 248 | ## Safety, usage, and risk notices 249 | 250 | ### Intended use 251 | 252 | - Personal / internal development only 253 | - Respect internal quotas and data handling policies 254 | - Not for production services or bypassing intended limits 255 | 256 | ### Not suitable for 257 | 258 | - Production application traffic 259 | - High-volume automated extraction 260 | - Any use that violates Acceptable Use Policies 261 | 262 | ### ⚠️ Warning (assumption of risk) 263 | 264 | By using this plugin, you acknowledge and accept the following: 265 | 266 | - **Terms of Service risk:** This approach may violate the Terms of Service of AI model providers (Anthropic, OpenAI, etc.). You are solely responsible for ensuring compliance with all applicable terms and policies. 267 | - **Account risk:** Providers may detect this usage pattern and take punitive action, including suspension, permanent ban, or loss of access to paid subscriptions. 268 | - **No guarantees:** Providers may change APIs, authentication, or policies at any time, which can break this method without notice. 269 | - **Assumption of risk:** You assume all legal, financial, and technical risks. The authors and contributors of this project bear no responsibility for any consequences arising from your use. 270 | 271 | Use at your own risk. Proceed only if you understand and accept these risks. 272 | 273 | ## Legal 274 | 275 | - Not affiliated with Google. This is an independent open-source project and is not endorsed by, sponsored by, or affiliated with Google LLC. 276 | - "Antigravity", "Gemini", "Google Cloud", and "Google" are trademarks of Google LLC. 277 | - Software is provided "as is", without warranty. You are responsible for complying with Google's Terms of Service and Acceptable Use Policy. 278 | 279 | ## Credits 280 | 281 | Built with help and inspiration from: 282 | 283 | - [opencode-gemini-auth](https://github.com/jenslys/opencode-gemini-auth) — Gemini OAuth groundwork by [@jenslys](https://github.com/jenslys) 284 | - [CLIProxyAPI](https://github.com/router-for-me/CLIProxyAPI) — Helpful reference for Antigravity API 285 | 286 | ## Support 287 | 288 | If this plugin helps you, consider supporting its continued maintenance: 289 | 290 | [![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/S6S81QBOIR) 291 | 292 | 293 | -------------------------------------------------------------------------------- /docs/ANTIGRAVITY_API_SPEC.md: -------------------------------------------------------------------------------- 1 | # Antigravity Unified Gateway API Specification 2 | 3 | **Version:** 1.0 4 | **Last Updated:** December 13, 2025 5 | **Status:** Verified by Direct API Testing 6 | 7 | --- 8 | 9 | ## Overview 10 | 11 | Antigravity is Google's **Unified Gateway API** for accessing multiple AI models (Claude, Gemini, GPT-OSS) through a single, consistent Gemini-style interface. It is NOT the same as Vertex AI's direct model APIs. 12 | 13 | ### Key Characteristics 14 | 15 | - **Single API format** for all models (Gemini-style) 16 | - **Project-based access** via Google Cloud authentication 17 | - **Internal routing** to model backends (Vertex AI for Claude, Gemini API for Gemini) 18 | - **Unified response format** (`candidates[]` structure for all models) 19 | 20 | --- 21 | 22 | ## Endpoints 23 | 24 | | Environment | URL | Status | 25 | |-------------|-----|--------| 26 | | **Daily (Sandbox)** | `https://daily-cloudcode-pa.sandbox.googleapis.com` | ✅ Active | 27 | | **Production** | `https://cloudcode-pa.googleapis.com` | ✅ Active | 28 | | **Autopush (Sandbox)** | `https://autopush-cloudcode-pa.sandbox.googleapis.com` | ❌ Unavailable | 29 | 30 | ### API Actions 31 | 32 | | Action | Path | Description | 33 | |--------|------|-------------| 34 | | Generate Content | `/v1internal:generateContent` | Non-streaming request | 35 | | Stream Generate | `/v1internal:streamGenerateContent?alt=sse` | Streaming (SSE) request | 36 | | Load Code Assist | `/v1internal:loadCodeAssist` | Project discovery | 37 | | Onboard User | `/v1internal:onboardUser` | User onboarding | 38 | 39 | --- 40 | 41 | ## Authentication 42 | 43 | ### OAuth 2.0 Setup 44 | 45 | ``` 46 | Authorization URL: https://accounts.google.com/o/oauth2/auth 47 | Token URL: https://oauth2.googleapis.com/token 48 | ``` 49 | 50 | ### Required Scopes 51 | 52 | ``` 53 | https://www.googleapis.com/auth/cloud-platform 54 | https://www.googleapis.com/auth/userinfo.email 55 | https://www.googleapis.com/auth/userinfo.profile 56 | https://www.googleapis.com/auth/cclog 57 | https://www.googleapis.com/auth/experimentsandconfigs 58 | ``` 59 | 60 | ### Required Headers 61 | 62 | ```http 63 | Authorization: Bearer {access_token} 64 | Content-Type: application/json 65 | User-Agent: antigravity/1.11.5 windows/amd64 66 | X-Goog-Api-Client: google-cloud-sdk vscode_cloudshelleditor/0.1 67 | Client-Metadata: {"ideType":"IDE_UNSPECIFIED","platform":"PLATFORM_UNSPECIFIED","pluginType":"GEMINI"} 68 | ``` 69 | 70 | For streaming requests, also include: 71 | ```http 72 | Accept: text/event-stream 73 | ``` 74 | 75 | --- 76 | 77 | ## Available Models 78 | 79 | | Model Name | Model ID | Type | Status | 80 | |------------|----------|------|--------| 81 | | Claude Sonnet 4.5 | `claude-sonnet-4-5` | Anthropic | ✅ Verified | 82 | | Claude Sonnet 4.5 Thinking | `claude-sonnet-4-5-thinking` | Anthropic | ✅ Verified | 83 | | Claude Opus 4.5 Thinking | `claude-opus-4-5-thinking` | Anthropic | ✅ Verified | 84 | | Gemini 3 Pro High | `gemini-3-pro-high` | Google | ✅ Verified | 85 | | Gemini 3 Pro Low | `gemini-3-pro-low` | Google | ✅ Verified | 86 | | GPT-OSS 120B Medium | `gpt-oss-120b-medium` | Other | ✅ Verified | 87 | 88 | --- 89 | 90 | ## Request Format 91 | 92 | ### Basic Structure 93 | 94 | ```json 95 | { 96 | "project": "{project_id}", 97 | "model": "{model_id}", 98 | "request": { 99 | "contents": [...], 100 | "generationConfig": {...}, 101 | "systemInstruction": {...}, 102 | "tools": [...] 103 | }, 104 | "userAgent": "antigravity", 105 | "requestId": "{unique_id}" 106 | } 107 | ``` 108 | 109 | ### Contents Array (REQUIRED) 110 | 111 | **⚠️ IMPORTANT: Must use Gemini-style format. Anthropic-style `messages` array is NOT supported.** 112 | 113 | ```json 114 | { 115 | "contents": [ 116 | { 117 | "role": "user", 118 | "parts": [ 119 | { "text": "Your message here" } 120 | ] 121 | }, 122 | { 123 | "role": "model", 124 | "parts": [ 125 | { "text": "Assistant response" } 126 | ] 127 | } 128 | ] 129 | } 130 | ``` 131 | 132 | #### Role Values 133 | - `user` - Human/user messages 134 | - `model` - Assistant responses (NOT `assistant`) 135 | 136 | ### Generation Config 137 | 138 | ```json 139 | { 140 | "generationConfig": { 141 | "maxOutputTokens": 1000, 142 | "temperature": 0.7, 143 | "topP": 0.95, 144 | "topK": 40, 145 | "stopSequences": ["STOP"], 146 | "thinkingConfig": { 147 | "thinkingBudget": 8000, 148 | "includeThoughts": true 149 | } 150 | } 151 | } 152 | ``` 153 | 154 | | Field | Type | Description | 155 | |-------|------|-------------| 156 | | `maxOutputTokens` | number | Maximum tokens in response | 157 | | `temperature` | number | Randomness (0.0 - 2.0) | 158 | | `topP` | number | Nucleus sampling threshold | 159 | | `topK` | number | Top-K sampling | 160 | | `stopSequences` | string[] | Stop generation triggers | 161 | | `thinkingConfig` | object | Extended thinking config | 162 | 163 | ### System Instructions 164 | 165 | **⚠️ Must be an object with `parts`, NOT a plain string.** 166 | 167 | ```json 168 | // ✅ CORRECT 169 | { 170 | "systemInstruction": { 171 | "parts": [ 172 | { "text": "You are a helpful assistant." } 173 | ] 174 | } 175 | } 176 | 177 | // ❌ WRONG - Will return 400 error 178 | { 179 | "systemInstruction": "You are a helpful assistant." 180 | } 181 | ``` 182 | 183 | ### Tools / Function Calling 184 | 185 | ```json 186 | { 187 | "tools": [ 188 | { 189 | "functionDeclarations": [ 190 | { 191 | "name": "get_weather", 192 | "description": "Get weather for a location", 193 | "parameters": { 194 | "type": "object", 195 | "properties": { 196 | "location": { 197 | "type": "string", 198 | "description": "City name" 199 | } 200 | }, 201 | "required": ["location"] 202 | } 203 | } 204 | ] 205 | } 206 | ] 207 | } 208 | ``` 209 | 210 | ### Function Name Rules 211 | 212 | | Rule | Description | 213 | |------|-------------| 214 | | First character | Must be a letter (a-z, A-Z) or underscore (_) | 215 | | Allowed characters | `a-zA-Z0-9`, underscores (`_`), dots (`.`), colons (`:`), dashes (`-`) | 216 | | Max length | 64 characters | 217 | | Not allowed | Slashes (`/`), spaces, other special characters | 218 | 219 | **Examples:** 220 | - ✅ `get_weather` - Valid 221 | - ✅ `mcp:mongodb.query` - Valid (colons and dots allowed) 222 | - ✅ `read-file` - Valid (dashes allowed) 223 | - ❌ `mcp/query` - Invalid (slashes not allowed) 224 | - ❌ `123_tool` - Invalid (must start with letter or underscore) 225 | 226 | ### JSON Schema Support 227 | 228 | | Feature | Status | Notes | 229 | |---------|--------|-------| 230 | | `type` | ✅ Supported | `object`, `string`, `number`, `integer`, `boolean`, `array` | 231 | | `properties` | ✅ Supported | Object properties | 232 | | `required` | ✅ Supported | Required fields array | 233 | | `description` | ✅ Supported | Field descriptions | 234 | | `enum` | ✅ Supported | Enumerated values | 235 | | `items` | ✅ Supported | Array item schema | 236 | | `anyOf` | ✅ Supported | Converted to `any_of` internally | 237 | | `allOf` | ✅ Supported | Converted to `all_of` internally | 238 | | `oneOf` | ✅ Supported | Converted to `one_of` internally | 239 | | `additionalProperties` | ✅ Supported | Additional properties schema | 240 | | `const` | ❌ NOT Supported | Use `enum: [value]` instead | 241 | | `$ref` | ❌ NOT Supported | Inline the schema instead | 242 | | `$defs` / `definitions` | ❌ NOT Supported | Inline definitions instead | 243 | | `$schema` | ❌ NOT Supported | Strip from schema | 244 | | `$id` | ❌ NOT Supported | Strip from schema | 245 | | `default` | ❌ NOT Supported | Strip from schema | 246 | | `examples` | ❌ NOT Supported | Strip from schema | 247 | | `title` (nested) | ⚠️ Caution | May cause issues in nested objects | 248 | 249 | **⚠️ IMPORTANT:** The following features will cause a 400 error if sent to the API: 250 | - `const` - Convert to `enum: [value]` instead 251 | - `$ref` / `$defs` - Inline the schema definitions 252 | - `$schema` / `$id` - Strip these metadata fields 253 | - `default` / `examples` - Strip these documentation fields 254 | 255 | ```json 256 | // ❌ WRONG - Will return 400 error 257 | { "type": { "const": "email" } } 258 | 259 | // ✅ CORRECT - Use enum instead 260 | { "type": { "enum": ["email"] } } 261 | ``` 262 | 263 | **Note:** The plugin automatically handles these conversions via the `schema-transform.ts` module. 264 | 265 | --- 266 | 267 | ## Response Format 268 | 269 | ### Non-Streaming Response 270 | 271 | ```json 272 | { 273 | "response": { 274 | "candidates": [ 275 | { 276 | "content": { 277 | "role": "model", 278 | "parts": [ 279 | { "text": "Response text here" } 280 | ] 281 | }, 282 | "finishReason": "STOP" 283 | } 284 | ], 285 | "usageMetadata": { 286 | "promptTokenCount": 16, 287 | "candidatesTokenCount": 4, 288 | "totalTokenCount": 20 289 | }, 290 | "modelVersion": "claude-sonnet-4-5", 291 | "responseId": "msg_vrtx_..." 292 | }, 293 | "traceId": "abc123..." 294 | } 295 | ``` 296 | 297 | ### Streaming Response (SSE) 298 | 299 | Content-Type: `text/event-stream` 300 | 301 | ``` 302 | data: {"response": {"candidates": [{"content": {"role": "model", "parts": [{"text": "Hello"}]}}], "usageMetadata": {...}, "modelVersion": "...", "responseId": "..."}, "traceId": "..."} 303 | 304 | data: {"response": {"candidates": [{"content": {"role": "model", "parts": [{"text": " world"}]}, "finishReason": "STOP"}], "usageMetadata": {...}}, "traceId": "..."} 305 | 306 | ``` 307 | 308 | ### Response Fields 309 | 310 | | Field | Description | 311 | |-------|-------------| 312 | | `response.candidates` | Array of response candidates | 313 | | `response.candidates[].content.role` | Always `"model"` | 314 | | `response.candidates[].content.parts` | Array of content parts | 315 | | `response.candidates[].finishReason` | `STOP`, `MAX_TOKENS`, `OTHER` | 316 | | `response.usageMetadata.promptTokenCount` | Input tokens | 317 | | `response.usageMetadata.candidatesTokenCount` | Output tokens | 318 | | `response.usageMetadata.totalTokenCount` | Total tokens | 319 | | `response.usageMetadata.thoughtsTokenCount` | Thinking tokens (Gemini) | 320 | | `response.modelVersion` | Actual model used | 321 | | `response.responseId` | Request ID (format varies by model) | 322 | | `traceId` | Trace ID for debugging | 323 | 324 | ### Response ID Formats 325 | 326 | | Model Type | Format | Example | 327 | |------------|--------|---------| 328 | | Claude | `msg_vrtx_...` | `msg_vrtx_01UDKZG8PWPj9mjajje8d7u7` | 329 | | Gemini | Base64-like | `ypM9abPqFKWl0-kPvamgqQw` | 330 | | GPT-OSS | Base64-like | `y5M9aZaSKq6z2roPoJ7pEA` | 331 | 332 | --- 333 | 334 | ## Function Call Response 335 | 336 | When the model wants to call a function: 337 | 338 | ```json 339 | { 340 | "response": { 341 | "candidates": [ 342 | { 343 | "content": { 344 | "role": "model", 345 | "parts": [ 346 | { 347 | "functionCall": { 348 | "name": "get_weather", 349 | "args": { 350 | "location": "Paris" 351 | }, 352 | "id": "toolu_vrtx_01PDbPTJgBJ3AJ8BCnSXvUqk" 353 | } 354 | } 355 | ] 356 | }, 357 | "finishReason": "OTHER" 358 | } 359 | ] 360 | } 361 | } 362 | ``` 363 | 364 | ### Providing Function Results 365 | 366 | ```json 367 | { 368 | "contents": [ 369 | { "role": "user", "parts": [{ "text": "What's the weather?" }] }, 370 | { "role": "model", "parts": [{ "functionCall": { "name": "get_weather", "args": {...}, "id": "..." } }] }, 371 | { "role": "user", "parts": [{ "functionResponse": { "name": "get_weather", "id": "...", "response": { "temperature": "22C" } } }] } 372 | ] 373 | } 374 | ``` 375 | 376 | --- 377 | 378 | ## Thinking / Extended Reasoning 379 | 380 | ### Thinking Config 381 | 382 | For thinking-capable models (`*-thinking`), use: 383 | 384 | ```json 385 | { 386 | "generationConfig": { 387 | "maxOutputTokens": 10000, 388 | "thinkingConfig": { 389 | "thinkingBudget": 8000, 390 | "includeThoughts": true 391 | } 392 | } 393 | } 394 | ``` 395 | 396 | **⚠️ IMPORTANT: `maxOutputTokens` must be GREATER than `thinkingBudget`** 397 | 398 | ### Thinking Response (Gemini) 399 | 400 | Gemini models return thinking with signatures: 401 | 402 | ```json 403 | { 404 | "parts": [ 405 | { 406 | "thoughtSignature": "ErADCq0DAXLI2nx...", 407 | "text": "Let me think about this..." 408 | }, 409 | { 410 | "text": "The answer is..." 411 | } 412 | ] 413 | } 414 | ``` 415 | 416 | ### Thinking Response (Claude) 417 | 418 | Claude thinking models may include `thought: true` parts: 419 | 420 | ```json 421 | { 422 | "parts": [ 423 | { 424 | "thought": true, 425 | "text": "Reasoning process...", 426 | "thoughtSignature": "..." 427 | }, 428 | { 429 | "text": "Final answer..." 430 | } 431 | ] 432 | } 433 | ``` 434 | 435 | --- 436 | 437 | ## Error Responses 438 | 439 | ### Error Structure 440 | 441 | ```json 442 | { 443 | "error": { 444 | "code": 400, 445 | "message": "Error description", 446 | "status": "INVALID_ARGUMENT", 447 | "details": [...] 448 | } 449 | } 450 | ``` 451 | 452 | ### Common Error Codes 453 | 454 | | Code | Status | Description | 455 | |------|--------|-------------| 456 | | 400 | `INVALID_ARGUMENT` | Invalid request format | 457 | | 401 | `UNAUTHENTICATED` | Invalid/expired token | 458 | | 403 | `PERMISSION_DENIED` | No access to resource | 459 | | 404 | `NOT_FOUND` | Model not found | 460 | | 429 | `RESOURCE_EXHAUSTED` | Rate limit exceeded | 461 | 462 | ### Rate Limit Response 463 | 464 | ```json 465 | { 466 | "error": { 467 | "code": 429, 468 | "message": "You have exhausted your capacity on this model. Your quota will reset after 3s.", 469 | "status": "RESOURCE_EXHAUSTED", 470 | "details": [ 471 | { 472 | "@type": "type.googleapis.com/google.rpc.RetryInfo", 473 | "retryDelay": "3.957525076s" 474 | } 475 | ] 476 | } 477 | } 478 | ``` 479 | 480 | --- 481 | 482 | ## NOT Supported 483 | 484 | The following Anthropic/Vertex AI features are **NOT supported**: 485 | 486 | | Feature | Error | 487 | |---------|-------| 488 | | `anthropic_version` | Unknown field | 489 | | `messages` array | Unknown field | 490 | | `max_tokens` | Unknown field | 491 | | Plain string `systemInstruction` | Invalid value | 492 | | `system_instruction` (snake_case at root) | Unknown field | 493 | | JSON Schema `const` | Unknown field (use `enum: [value]`) | 494 | | JSON Schema `$ref` | Not supported (inline instead) | 495 | | JSON Schema `$defs` | Not supported (inline instead) | 496 | | Tool names with `/` | Invalid (use `_` or `:` instead) | 497 | | Tool names starting with digit | Invalid (must start with letter/underscore) | 498 | 499 | --- 500 | 501 | ## Complete Request Example 502 | 503 | ```json 504 | { 505 | "project": "my-project-id", 506 | "model": "claude-sonnet-4-5", 507 | "request": { 508 | "contents": [ 509 | { 510 | "role": "user", 511 | "parts": [ 512 | { "text": "Hello, how are you?" } 513 | ] 514 | } 515 | ], 516 | "systemInstruction": { 517 | "parts": [ 518 | { "text": "You are a helpful assistant." } 519 | ] 520 | }, 521 | "generationConfig": { 522 | "maxOutputTokens": 1000, 523 | "temperature": 0.7 524 | } 525 | }, 526 | "userAgent": "antigravity", 527 | "requestId": "agent-abc123" 528 | } 529 | ``` 530 | 531 | --- 532 | 533 | ## Response Headers 534 | 535 | | Header | Description | 536 | |--------|-------------| 537 | | `x-cloudaicompanion-trace-id` | Trace ID for debugging | 538 | | `server-timing` | Request duration | 539 | 540 | --- 541 | 542 | ## Comparison: Antigravity vs Vertex AI Anthropic 543 | 544 | | Feature | Antigravity | Vertex AI Anthropic | 545 | |---------|-------------|---------------------| 546 | | Endpoint | `cloudcode-pa.googleapis.com` | `aiplatform.googleapis.com` | 547 | | Request format | Gemini-style `contents` | Anthropic `messages` | 548 | | `anthropic_version` | Not used | Required | 549 | | Model names | Simple (`claude-sonnet-4-5`) | Versioned (`claude-4-5@date`) | 550 | | Response format | `candidates[]` | Anthropic `content[]` | 551 | | Multi-model support | Yes (Claude, Gemini, etc.) | Anthropic only | 552 | 553 | --- 554 | 555 | ## Changelog 556 | 557 | - **2025-12-14**: Added function calling quirks, JSON Schema support matrix, tool name rules 558 | - **2025-12-13**: Initial specification based on direct API testing 559 | -------------------------------------------------------------------------------- /docs/CLAUDE_MODEL_FLOW.md: -------------------------------------------------------------------------------- 1 | # Claude Model Flow: OpenCode → Plugin → Antigravity API 2 | 3 | **Version:** 1.1 4 | **Last Updated:** December 2025 5 | **Branches:** `claude-improvements`, `improve-tools-call-sanitizer` 6 | 7 | --- 8 | 9 | ## Overview 10 | 11 | This document explains how Claude models are handled through the Antigravity plugin, including the full request/response flow, all quirks and adaptations, and recent improvements. 12 | 13 | ### Why Special Handling? 14 | 15 | Claude models via Antigravity require special handling because: 16 | 17 | 1. **Gemini-style format** - Antigravity uses `contents[]` with `parts[]`, not Anthropic's `messages[]` 18 | 2. **Thinking signatures** - Multi-turn conversations require signed thinking blocks 19 | 3. **Tool schema restrictions** - Claude rejects unsupported JSON Schema features (`const`, `$ref`, etc.) 20 | 4. **SDK injection** - OpenCode SDKs may inject fields (`cache_control`) that Claude rejects 21 | 5. **OpenCode expectations** - Response format must be transformed to match OpenCode's expected structure 22 | 23 | --- 24 | 25 | ## Full Request Flow 26 | 27 | ``` 28 | ┌─────────────────────────────────────────────────────────────┐ 29 | │ OpenCode Request (OpenAI-style) │ 30 | │ POST to generativelanguage.googleapis.com/models/claude-* │ 31 | └─────────────────────────────────────────────────────────────┘ 32 | ↓ 33 | ┌─────────────────────────────────────────────────────────────┐ 34 | │ plugin.ts (fetch interceptor) │ 35 | │ • Account selection & round-robin rotation │ 36 | │ • Token refresh if expired │ 37 | │ • Rate limit handling (429 → switch account or wait) │ 38 | │ • Endpoint fallback (daily → autopush → prod) │ 39 | └─────────────────────────────────────────────────────────────┘ 40 | ↓ 41 | ┌─────────────────────────────────────────────────────────────┐ 42 | │ request.ts :: prepareAntigravityRequest() │ 43 | │ • Detect Claude model from URL │ 44 | │ • Set toolConfig.functionCallingConfig.mode = "VALIDATED" │ 45 | │ • Configure thinkingConfig for *-thinking models │ 46 | │ • Sanitize tool schemas (allowlist approach) │ 47 | │ • Add placeholder property for empty tool schemas │ 48 | │ • Filter unsigned thinking blocks from history │ 49 | │ • Restore signatures from cache if available │ 50 | │ • Assign tool call/response IDs (FIFO matching) │ 51 | │ • Inject interleaved-thinking system hint │ 52 | │ • Add anthropic-beta: interleaved-thinking-2025-05-14 │ 53 | │ • Wrap in Antigravity format: {project, model, request} │ 54 | └─────────────────────────────────────────────────────────────┘ 55 | ↓ 56 | ┌─────────────────────────────────────────────────────────────┐ 57 | │ Antigravity API │ 58 | │ POST https://cloudcode-pa.googleapis.com/v1internal:* │ 59 | │ • Gemini-style request format │ 60 | │ • Returns SSE stream with candidates[] structure │ 61 | └─────────────────────────────────────────────────────────────┘ 62 | ↓ 63 | ┌─────────────────────────────────────────────────────────────┐ 64 | │ request.ts :: transformAntigravityResponse() │ 65 | │ • Real-time SSE TransformStream (line-by-line) │ 66 | │ • Cache thinking signatures for multi-turn reuse │ 67 | │ • Transform thought parts → reasoning format │ 68 | │ • Unwrap response envelope for OpenCode │ 69 | │ • Extract and forward usage metadata │ 70 | └─────────────────────────────────────────────────────────────┘ 71 | ↓ 72 | ┌─────────────────────────────────────────────────────────────┐ 73 | │ OpenCode Response (streamed incrementally) │ 74 | │ Thinking tokens visible as they arrive │ 75 | │ Format: type: "reasoning" with thought: true │ 76 | └─────────────────────────────────────────────────────────────┘ 77 | ``` 78 | 79 | --- 80 | 81 | ## Claude-Specific Quirks & Adaptations 82 | 83 | This section documents all 36 quirks and adaptations required for Claude models to work properly through the Antigravity unified gateway and with OpenCode. 84 | 85 | ### 1. Request Format Quirks 86 | 87 | These quirks handle the translation from OpenCode/Anthropic format to Antigravity's Gemini-style format. 88 | 89 | | # | Quirk | Problem | Adaptation | 90 | |---|-------|---------|------------| 91 | | 1 | **Message format** | Antigravity uses Gemini-style, not Anthropic | Transform `messages[]` → `contents[].parts[]` | 92 | | 2 | **Role names** | Claude uses `assistant` | Map to `model` for Antigravity | 93 | | 3 | **System instruction format** | Plain string rejected (400 error) | Wrap in `{ parts: [{ text: "..." }] }` | 94 | | 4 | **Field name casing** | `system_instruction` (snake_case) rejected | Convert to `systemInstruction` (camelCase) | 95 | 96 | **Example - System Instruction:** 97 | ```json 98 | // ❌ WRONG - Returns 400 error 99 | { "systemInstruction": "You are helpful." } 100 | 101 | // ✅ CORRECT - Wrapped format 102 | { "systemInstruction": { "parts": [{ "text": "You are helpful." }] } } 103 | ``` 104 | 105 | ### 2. Tool/Function Calling Quirks 106 | 107 | These quirks ensure tools work correctly with Claude's VALIDATED mode via Antigravity. 108 | 109 | | # | Quirk | Problem | Adaptation | 110 | |---|-------|---------|------------| 111 | | 5 | **VALIDATED mode** | Default mode may fail | Force `toolConfig.functionCallingConfig.mode = "VALIDATED"` | 112 | | 6 | **Unsupported schema features** | `const`, `$ref`, `$defs`, `default`, `examples` cause 400 | Allowlist-based `sanitizeSchema()` strips all unsupported fields | 113 | | 7 | **`const` keyword** | Not supported by gateway | Convert `const: "value"` → `enum: ["value"]` | 114 | | 8 | **Empty schemas** | VALIDATED mode fails on `{type: "object"}` with no properties | Add placeholder `reason` property with `required: ["reason"]` | 115 | | 9 | **Empty `items`** | `items: {}` is invalid | Convert to `items: { type: "string" }` | 116 | | 10 | **Tool name characters** | Special chars like `/` rejected | Replace `[^a-zA-Z0-9_-]` with `_`, max 64 chars | 117 | | 11 | **Tool structure variants** | SDKs send various formats (function, custom, etc.) | Normalize all to `{ functionDeclarations: [...] }` | 118 | | 12 | **Tool call/response IDs** | Claude requires matching IDs for function responses | Assign IDs via FIFO queue per function name | 119 | 120 | **Schema Allowlist (only these fields are kept):** 121 | - `type`, `properties`, `required`, `description`, `enum`, `items`, `additionalProperties` 122 | 123 | **Example - Const Conversion:** 124 | ```json 125 | // ❌ WRONG - Returns 400 error 126 | { "type": { "const": "email" } } 127 | 128 | // ✅ CORRECT - Converted by plugin 129 | { "type": { "enum": ["email"] } } 130 | ``` 131 | 132 | **Example - Empty Schema Fix:** 133 | ```json 134 | // ❌ WRONG - VALIDATED mode fails 135 | { "type": "object", "properties": {} } 136 | 137 | // ✅ CORRECT - Placeholder added 138 | { 139 | "type": "object", 140 | "properties": { 141 | "reason": { "type": "string", "description": "Brief explanation of why you are calling this tool" } 142 | }, 143 | "required": ["reason"] 144 | } 145 | ``` 146 | 147 | ### 3. Thinking Block Quirks (Multi-turn) 148 | 149 | These quirks handle Claude's requirement for signed thinking blocks in multi-turn conversations. 150 | 151 | | # | Quirk | Problem | Adaptation | 152 | |---|-------|---------|------------| 153 | | 13 | **Signature requirement** | Multi-turn needs signed thinking blocks or 400 error | Cache signatures by session ID + text hash | 154 | | 14 | **`cache_control` in thinking** | SDK injects, Claude rejects (400) | `stripCacheControlRecursively()` removes at any depth | 155 | | 15 | **`providerOptions` in thinking** | SDK injects, Claude rejects | Strip via `sanitizeThinkingPart()` | 156 | | 16 | **Wrapped `thinking` field** | SDK may wrap: `{ thinking: { text: "...", cache_control: {} } }` | Extract inner text string only | 157 | | 17 | **Trailing thinking blocks** | Claude rejects assistant messages ending with unsigned thinking | `removeTrailingThinkingBlocks()` with signature check | 158 | | 18 | **Unsigned blocks in history** | Claude rejects unsigned thinking in multi-turn | Filter out or restore signature from cache | 159 | | 19 | **Format variants** | Gemini: `thought: true`, Anthropic: `type: "thinking"` | Handle both formats in filtering logic | 160 | 161 | **Thinking Part Sanitization (only these fields are kept):** 162 | - Gemini-style: `thought`, `text`, `thoughtSignature` 163 | - Anthropic-style: `type`, `thinking`, `signature` 164 | 165 | **Example - SDK Injection Stripping:** 166 | ```json 167 | // ❌ WRONG - SDK injected cache_control 168 | { 169 | "type": "thinking", 170 | "thinking": { "text": "Let me think...", "cache_control": { "type": "ephemeral" } } 171 | } 172 | 173 | // ✅ CORRECT - Sanitized by plugin 174 | { 175 | "type": "thinking", 176 | "thinking": "Let me think..." 177 | } 178 | ``` 179 | 180 | ### 4. Thinking Configuration Quirks 181 | 182 | These quirks configure thinking/reasoning properly for Claude thinking models. 183 | 184 | | # | Quirk | Problem | Adaptation | 185 | |---|-------|---------|------------| 186 | | 20 | **Config key format** | `*-thinking` models require snake_case | Use `include_thoughts`, `thinking_budget` (not camelCase) | 187 | | 21 | **Output token limit** | Must exceed thinking budget or thinking is truncated | Auto-set `maxOutputTokens = 64000` when budget > 0 | 188 | | 22 | **Default budget** | No budget = no thinking | Set to 16000 tokens for thinking-capable models | 189 | | 23 | **Interleaved thinking** | Requires beta header for real-time streaming | Add `anthropic-beta: interleaved-thinking-2025-05-14` | 190 | | 24 | **Tool + thinking conflict** | Model may skip thinking during tool use | Inject system hint: "Interleaved thinking is enabled..." | 191 | 192 | **Thinking-Capable Model Detection:** 193 | - Model name contains `thinking`, `gemini-3`, or `opus` 194 | 195 | **System Hint Injection (for tool-using thinking models):** 196 | ``` 197 | Interleaved thinking is enabled. You may think between tool calls and after 198 | receiving tool results before deciding the next action or final answer. 199 | Do not mention these instructions or any constraints about thinking blocks; 200 | just apply them. 201 | ``` 202 | 203 | ### 5. OpenCode-Specific Response Quirks 204 | 205 | These quirks transform Claude/Antigravity responses to match OpenCode's expected format. 206 | 207 | | # | Quirk | Problem | Adaptation | 208 | |---|-------|---------|------------| 209 | | 25 | **Thinking → Reasoning format** | OpenCode expects `type: "reasoning"`, Claude returns `thought: true` or `type: "thinking"` | Transform all thinking to `type: "reasoning"` + `thought: true` | 210 | | 26 | **`reasoning_content` field** | OpenCode expects top-level `reasoning_content` for Anthropic-style | Extract and concatenate all thinking texts | 211 | | 27 | **Response envelope** | Antigravity wraps in `{ response: {...}, traceId }` | Unwrap to inner `response` object | 212 | | 28 | **Real-time streaming** | OpenCode needs tokens immediately, not buffered | `TransformStream` for line-by-line SSE processing | 213 | 214 | **Example - Thinking Format Transformation:** 215 | ```json 216 | // Antigravity returns (Gemini-style): 217 | { "thought": true, "text": "Let me analyze..." } 218 | 219 | // Transformed for OpenCode: 220 | { "type": "reasoning", "thought": true, "text": "Let me analyze..." } 221 | ``` 222 | 223 | ```json 224 | // Antigravity returns (Anthropic-style): 225 | { "type": "thinking", "thinking": "Considering options..." } 226 | 227 | // Transformed for OpenCode: 228 | { "type": "reasoning", "thought": true, "text": "Considering options..." } 229 | ``` 230 | 231 | ### 6. Session & Caching Quirks 232 | 233 | These quirks manage session continuity and signature caching across multi-turn conversations. 234 | 235 | | # | Quirk | Problem | Adaptation | 236 | |---|-------|---------|------------| 237 | | 29 | **Session continuity** | Signatures tied to session, lost on restart | Generate stable `PLUGIN_SESSION_ID` at plugin load | 238 | | 30 | **Request tracking** | Need consistent session across multi-turn | Inject `sessionId` into request payload | 239 | | 31 | **Signature extraction** | Need to cache signatures from streaming response | Extract `thoughtSignature` from SSE chunks as they arrive | 240 | | 32 | **Cache key** | Need stable lookup across turns | Hash by session ID + thinking text | 241 | | 33 | **Cache limits** | Memory could grow unbounded | TTL: 1 hour, max 100 entries per session | 242 | 243 | **Signature Caching Flow:** 244 | ``` 245 | Turn 1 (Response): 246 | Claude returns: { thought: true, text: "...", thoughtSignature: "abc123..." } 247 | Plugin caches: hash("...") → "abc123..." 248 | 249 | Turn 2 (Request): 250 | OpenCode sends thinking block without signature 251 | Plugin looks up: hash("...") → "abc123..." 252 | Plugin restores signature before sending to Antigravity 253 | ``` 254 | 255 | ### 7. Error Handling Quirks 256 | 257 | These quirks improve error handling and debugging for Claude requests. 258 | 259 | | # | Quirk | Problem | Adaptation | 260 | |---|-------|---------|------------| 261 | | 34 | **Rate limit format** | `RetryInfo.retryDelay: "3.957s"` not standard HTTP | Parse to `Retry-After` and `retry-after-ms` headers | 262 | | 35 | **Debug visibility** | Errors lack context for debugging | Inject model, project, endpoint, status into error message | 263 | | 36 | **Preview access** | 404 for unenrolled users is confusing | Rewrite with preview access link | 264 | 265 | **Example - Enhanced Error Message:** 266 | ``` 267 | Original error: "Model not found" 268 | 269 | Enhanced by plugin: 270 | "Model not found 271 | 272 | [Debug Info] 273 | Requested Model: claude-sonnet-4-5-thinking 274 | Effective Model: claude-sonnet-4-5-thinking 275 | Project: my-project-id 276 | Endpoint: https://cloudcode-pa.googleapis.com/v1internal:streamGenerateContent 277 | Status: 404" 278 | ``` 279 | 280 | --- 281 | 282 | ## Improvements from `claude-improvements` Branch 283 | 284 | | Feature | Description | Location | 285 | |---------|-------------|----------| 286 | | **Signature Caching** | Cache thinking signatures by text hash for multi-turn conversations. Prevents "invalid signature" errors. | `cache.ts` | 287 | | **Real-time Streaming** | `TransformStream` processes SSE line-by-line for immediate token display | `request.ts:87-121` | 288 | | **Interleaved Thinking** | Auto-enable `anthropic-beta: interleaved-thinking-2025-05-14` header | `request.ts:813-824` | 289 | | **Validated Tool Calling** | Set `functionCallingConfig.mode = "VALIDATED"` for Claude models | `request.ts:314-325` | 290 | | **System Hints** | Auto-inject thinking hint into system instruction for tool-using models | `request.ts:396-434` | 291 | | **Output Token Safety** | Auto-set `maxOutputTokens = 64000` when thinking budget is enabled | `request.ts:358-377` | 292 | | **Stable Session ID** | Use `PLUGIN_SESSION_ID` across all requests for consistent signature caching | `request.ts:28` | 293 | 294 | --- 295 | 296 | ## Fixes from `improve-tools-call-sanitizer` Branch 297 | 298 | | Fix | Problem | Solution | Location | 299 | |-----|---------|----------|----------| 300 | | **Thinking Block Sanitization** | Claude API rejects `cache_control` and `providerOptions` inside thinking blocks | `sanitizeThinkingPart()` extracts only allowed fields (`type`, `thinking`, `signature`, `thought`, `text`, `thoughtSignature`) | `request-helpers.ts:179-215` | 301 | | **Deep Cache Control Strip** | SDK may nest `cache_control` in wrapped objects | `stripCacheControlRecursively()` removes at any depth | `request-helpers.ts:162-173` | 302 | | **Trailing Thinking Preservation** | Signed trailing thinking blocks were being incorrectly removed | `removeTrailingThinkingBlocks()` now checks `hasValidSignature()` before removal | `request-helpers.ts:125-131` | 303 | | **Signature Validation** | Need to identify valid signatures | `hasValidSignature()` checks for string ≥50 chars | `request-helpers.ts:137-140` | 304 | | **Schema Sanitization** | Claude rejects `const`, `$ref`, `$defs`, `default`, `examples` | Allowlist-based `sanitizeSchema()` keeps only basic features | `request.ts:468-523` | 305 | | **Empty Schema Fix** | Claude VALIDATED mode fails on `{type: "object"}` with no properties | Add placeholder `reason` property with `required: ["reason"]` | `request.ts:529-539` | 306 | | **Const → Enum Conversion** | `const` not supported | Convert `const: "value"` to `enum: ["value"]` | `request.ts:489-491` | 307 | 308 | --- 309 | 310 | ## Key Components Reference 311 | 312 | ### `src/plugin.ts` 313 | Entry point. Intercepts `fetch()` for `generativelanguage.googleapis.com` requests. Manages account pool, token refresh, rate limits, and endpoint fallbacks. 314 | 315 | ### `src/plugin/request.ts` 316 | | Function | Purpose | 317 | |----------|---------| 318 | | `prepareAntigravityRequest()` | Transforms OpenAI-style → Antigravity wrapped format | 319 | | `transformAntigravityResponse()` | Processes SSE stream, caches signatures, transforms thinking parts | 320 | | `createStreamingTransformer()` | Real-time line-by-line SSE processing | 321 | | `cacheThinkingSignatures()` | Extracts and caches signatures from response stream | 322 | | `sanitizeSchema()` | Allowlist-based schema sanitization for tools | 323 | | `normalizeSchema()` | Adds placeholder for empty tool schemas | 324 | 325 | ### `src/plugin/request-helpers.ts` 326 | | Function | Purpose | 327 | |----------|---------| 328 | | `filterUnsignedThinkingBlocks()` | Filters/sanitizes thinking blocks in `contents[]` | 329 | | `filterMessagesThinkingBlocks()` | Same for Anthropic-style `messages[]` | 330 | | `sanitizeThinkingPart()` | Normalizes thinking block structure, strips SDK fields | 331 | | `stripCacheControlRecursively()` | Deep removal of `cache_control` and `providerOptions` | 332 | | `hasValidSignature()` | Validates signature presence and length (≥50 chars) | 333 | | `removeTrailingThinkingBlocks()` | Removes unsigned trailing thinking from assistant messages | 334 | | `getThinkingText()` | Extracts text from various thinking block formats | 335 | | `transformThinkingParts()` | Converts thinking → reasoning format for OpenCode | 336 | | `isThinkingCapableModel()` | Detects thinking-capable models by name | 337 | | `extractThinkingConfig()` | Extracts thinking config from various request locations | 338 | | `resolveThinkingConfig()` | Determines final thinking config based on model capabilities | 339 | | `normalizeThinkingConfig()` | Validates and normalizes thinking configuration | 340 | 341 | ### `src/plugin/cache.ts` 342 | | Function | Purpose | 343 | |----------|---------| 344 | | `cacheSignature()` | Store signature by session ID + text hash | 345 | | `getCachedSignature()` | Retrieve cached signature for restoration | 346 | | **TTL:** 1 hour | **Max:** 100 entries per session | 347 | 348 | --- 349 | 350 | ## Troubleshooting 351 | 352 | | Error | Cause | Solution | 353 | |-------|-------|----------| 354 | | `invalid thinking signature` | Signature lost in multi-turn | Restart `opencode` to reset signature cache | 355 | | `Unknown field: cache_control` | SDK injected unsupported field | Plugin auto-strips; update plugin if persists | 356 | | `Unknown field: const` | Schema uses `const` keyword | Plugin auto-converts to `enum`; check schema | 357 | | `Unknown field: $ref` | Schema uses JSON Schema references | Inline the referenced schema instead | 358 | | `400 INVALID_ARGUMENT` on tools | Unsupported schema feature | Plugin auto-sanitizes; check `ANTIGRAVITY_API_SPEC.md` | 359 | | `Empty args object` error | Tool has no parameters | Plugin adds placeholder `reason` property | 360 | | `Function name invalid` | Tool name contains `/` or starts with digit | Plugin auto-sanitizes names | 361 | | Thinking not visible | Thinking budget exhausted or output limit too low | Plugin auto-configures; check model config | 362 | | Thinking stops during tool use | Model not using interleaved thinking | Plugin injects system hint; ensure `*-thinking` model | 363 | | `404 NOT_FOUND` on model | Preview access not enabled | Request preview access via provided link | 364 | | Rate limited (429) | Quota exhausted | Plugin extracts `Retry-After`; wait or switch account | 365 | 366 | --- 367 | 368 | ## Changelog 369 | 370 | ### `improve-tools-call-sanitizer` Branch 371 | 372 | | Commit | Description | 373 | |--------|-------------| 374 | | `ae86e3a` | Enhanced `removeTrailingThinkingBlocks` to preserve blocks with valid signatures | 375 | | `08f9da9` | Added thinking block sanitization (`sanitizeThinkingPart`, `stripCacheControlRecursively`, `hasValidSignature`) | 376 | 377 | ### `claude-improvements` Branch 378 | 379 | | Commit | Description | 380 | |--------|-------------| 381 | | `314ac9d` | Added thinking signature caching for multi-turn stability | 382 | | `5a28b41` | Initial Claude improvements with streaming, interleaved thinking, validated tools | 383 | 384 | ### Documentation 385 | 386 | | Version | Description | 387 | |---------|-------------| 388 | | 1.1 | Added comprehensive "Claude-Specific Quirks & Adaptations" section with 36 quirks | 389 | | 1.0 | Initial documentation with flow diagram and branch summaries | 390 | 391 | --- 392 | 393 | ## See Also 394 | 395 | - [ANTIGRAVITY_API_SPEC.md](./ANTIGRAVITY_API_SPEC.md) - Full Antigravity API reference 396 | - [README.md](../README.md) - Plugin setup and usage 397 | -------------------------------------------------------------------------------- /src/plugin/request-helpers.ts: -------------------------------------------------------------------------------- 1 | const ANTIGRAVITY_PREVIEW_LINK = "https://goo.gle/enable-preview-features"; // TODO: Update to Antigravity link if available 2 | 3 | export interface AntigravityApiError { 4 | code?: number; 5 | message?: string; 6 | status?: string; 7 | [key: string]: unknown; 8 | } 9 | 10 | /** 11 | * Minimal representation of Antigravity API responses we touch. 12 | */ 13 | export interface AntigravityApiBody { 14 | response?: unknown; 15 | error?: AntigravityApiError; 16 | [key: string]: unknown; 17 | } 18 | 19 | /** 20 | * Usage metadata exposed by Antigravity responses. Fields are optional to reflect partial payloads. 21 | */ 22 | export interface AntigravityUsageMetadata { 23 | totalTokenCount?: number; 24 | promptTokenCount?: number; 25 | candidatesTokenCount?: number; 26 | cachedContentTokenCount?: number; 27 | } 28 | 29 | /** 30 | * Normalized thinking configuration accepted by Antigravity. 31 | */ 32 | export interface ThinkingConfig { 33 | thinkingBudget?: number; 34 | includeThoughts?: boolean; 35 | } 36 | 37 | /** 38 | * Default token budget for thinking/reasoning. 16000 tokens provides sufficient 39 | * space for complex reasoning while staying within typical model limits. 40 | */ 41 | export const DEFAULT_THINKING_BUDGET = 16000; 42 | 43 | /** 44 | * Checks if a model name indicates thinking/reasoning capability. 45 | * Models with "thinking", "gemini-3", or "opus" in their name support extended thinking. 46 | */ 47 | export function isThinkingCapableModel(modelName: string): boolean { 48 | const lowerModel = modelName.toLowerCase(); 49 | return lowerModel.includes("thinking") 50 | || lowerModel.includes("gemini-3") 51 | || lowerModel.includes("opus"); 52 | } 53 | 54 | /** 55 | * Extracts thinking configuration from various possible request locations. 56 | * Supports both Gemini-style thinkingConfig and Anthropic-style thinking options. 57 | */ 58 | export function extractThinkingConfig( 59 | requestPayload: Record, 60 | rawGenerationConfig: Record | undefined, 61 | extraBody: Record | undefined, 62 | ): ThinkingConfig | undefined { 63 | const thinkingConfig = rawGenerationConfig?.thinkingConfig 64 | ?? extraBody?.thinkingConfig 65 | ?? requestPayload.thinkingConfig; 66 | 67 | if (thinkingConfig && typeof thinkingConfig === "object") { 68 | const config = thinkingConfig as Record; 69 | return { 70 | includeThoughts: Boolean(config.includeThoughts), 71 | thinkingBudget: typeof config.thinkingBudget === "number" ? config.thinkingBudget : DEFAULT_THINKING_BUDGET, 72 | }; 73 | } 74 | 75 | // Convert Anthropic-style "thinking" option: { type: "enabled", budgetTokens: N } 76 | const anthropicThinking = extraBody?.thinking ?? requestPayload.thinking; 77 | if (anthropicThinking && typeof anthropicThinking === "object") { 78 | const thinking = anthropicThinking as Record; 79 | if (thinking.type === "enabled" || thinking.budgetTokens) { 80 | return { 81 | includeThoughts: true, 82 | thinkingBudget: typeof thinking.budgetTokens === "number" ? thinking.budgetTokens : DEFAULT_THINKING_BUDGET, 83 | }; 84 | } 85 | } 86 | 87 | return undefined; 88 | } 89 | 90 | /** 91 | * Determines the final thinking configuration based on model capabilities and user settings. 92 | * For Claude thinking models, we keep thinking enabled even in multi-turn conversations. 93 | * The filterUnsignedThinkingBlocks function will handle signature validation/restoration. 94 | */ 95 | export function resolveThinkingConfig( 96 | userConfig: ThinkingConfig | undefined, 97 | isThinkingModel: boolean, 98 | _isClaudeModel: boolean, 99 | _hasAssistantHistory: boolean, 100 | ): ThinkingConfig | undefined { 101 | // For thinking-capable models (including Claude thinking models), enable thinking by default 102 | // The signature validation/restoration is handled by filterUnsignedThinkingBlocks 103 | if (isThinkingModel && !userConfig) { 104 | return { includeThoughts: true, thinkingBudget: DEFAULT_THINKING_BUDGET }; 105 | } 106 | 107 | return userConfig; 108 | } 109 | 110 | /** 111 | * Checks if a part is a thinking/reasoning block (Anthropic or Gemini style). 112 | */ 113 | function isThinkingPart(part: Record): boolean { 114 | return part.type === "thinking" 115 | || part.type === "reasoning" 116 | || part.thinking !== undefined 117 | || part.thought === true; 118 | } 119 | 120 | /** 121 | * Removes trailing thinking blocks from a content array. 122 | * Claude API requires that assistant messages don't end with thinking blocks. 123 | * Only removes unsigned thinking blocks; preserves those with valid signatures. 124 | */ 125 | function removeTrailingThinkingBlocks(contentArray: any[]): any[] { 126 | const result = [...contentArray]; 127 | while (result.length > 0 && isThinkingPart(result[result.length - 1]) && !hasValidSignature(result[result.length - 1])) { 128 | result.pop(); 129 | } 130 | return result; 131 | } 132 | 133 | /** 134 | * Checks if a thinking part has a valid signature. 135 | * A valid signature is a non-empty string with at least 50 characters. 136 | */ 137 | function hasValidSignature(part: Record): boolean { 138 | const signature = part.thought === true ? part.thoughtSignature : part.signature; 139 | return typeof signature === "string" && signature.length >= 50; 140 | } 141 | 142 | /** 143 | * Gets the text content from a thinking part. 144 | */ 145 | function getThinkingText(part: Record): string { 146 | if (typeof part.text === "string") return part.text; 147 | if (typeof part.thinking === "string") return part.thinking; 148 | 149 | // Some SDKs wrap thinking in an object like { text: "...", cache_control: {...} } 150 | if (part.thinking && typeof part.thinking === "object") { 151 | const maybeText = (part.thinking as any).text ?? (part.thinking as any).thinking; 152 | if (typeof maybeText === "string") return maybeText; 153 | } 154 | 155 | return ""; 156 | } 157 | 158 | /** 159 | * Recursively strips cache_control and providerOptions from any object. 160 | * These fields can be injected by SDKs, but Claude rejects them inside thinking blocks. 161 | */ 162 | function stripCacheControlRecursively(obj: unknown): unknown { 163 | if (obj === null || obj === undefined) return obj; 164 | if (typeof obj !== "object") return obj; 165 | if (Array.isArray(obj)) return obj.map(item => stripCacheControlRecursively(item)); 166 | 167 | const result: Record = {}; 168 | for (const [key, value] of Object.entries(obj as Record)) { 169 | if (key === "cache_control" || key === "providerOptions") continue; 170 | result[key] = stripCacheControlRecursively(value); 171 | } 172 | return result; 173 | } 174 | 175 | /** 176 | * Sanitizes a thinking part by keeping only the allowed fields. 177 | * In particular, ensures `thinking` is a string (not an object with cache_control). 178 | */ 179 | function sanitizeThinkingPart(part: Record): Record { 180 | // Gemini-style thought blocks: { thought: true, text, thoughtSignature } 181 | if (part.thought === true) { 182 | const sanitized: Record = { thought: true }; 183 | 184 | if (part.text !== undefined) { 185 | // If text is wrapped, extract the inner string. 186 | if (typeof part.text === "object" && part.text !== null) { 187 | const maybeText = (part.text as any).text; 188 | sanitized.text = typeof maybeText === "string" ? maybeText : part.text; 189 | } else { 190 | sanitized.text = part.text; 191 | } 192 | } 193 | 194 | if (part.thoughtSignature !== undefined) sanitized.thoughtSignature = part.thoughtSignature; 195 | return sanitized; 196 | } 197 | 198 | // Anthropic-style thinking blocks: { type: "thinking", thinking, signature } 199 | if (part.type === "thinking" || part.thinking !== undefined) { 200 | const sanitized: Record = { type: "thinking" }; 201 | 202 | let thinkingContent: unknown = part.thinking ?? part.text; 203 | if (thinkingContent !== undefined && typeof thinkingContent === "object" && thinkingContent !== null) { 204 | const maybeText = (thinkingContent as any).text ?? (thinkingContent as any).thinking; 205 | thinkingContent = typeof maybeText === "string" ? maybeText : ""; 206 | } 207 | 208 | if (thinkingContent !== undefined) sanitized.thinking = thinkingContent; 209 | if (part.signature !== undefined) sanitized.signature = part.signature; 210 | return sanitized; 211 | } 212 | 213 | // Fallback: strip cache_control recursively. 214 | return stripCacheControlRecursively(part) as Record; 215 | } 216 | 217 | function filterContentArray( 218 | contentArray: any[], 219 | sessionId?: string, 220 | getCachedSignatureFn?: (sessionId: string, text: string) => string | undefined, 221 | ): any[] { 222 | const filtered: any[] = []; 223 | 224 | for (const item of contentArray) { 225 | if (!item || typeof item !== "object") { 226 | filtered.push(item); 227 | continue; 228 | } 229 | 230 | if (!isThinkingPart(item)) { 231 | filtered.push(item); 232 | continue; 233 | } 234 | 235 | if (hasValidSignature(item)) { 236 | filtered.push(sanitizeThinkingPart(item)); 237 | continue; 238 | } 239 | 240 | if (sessionId && getCachedSignatureFn) { 241 | const text = getThinkingText(item); 242 | if (text) { 243 | const cachedSignature = getCachedSignatureFn(sessionId, text); 244 | if (cachedSignature && cachedSignature.length >= 50) { 245 | const restoredPart = { ...item }; 246 | if ((item as any).thought === true) { 247 | (restoredPart as any).thoughtSignature = cachedSignature; 248 | } else { 249 | (restoredPart as any).signature = cachedSignature; 250 | } 251 | filtered.push(sanitizeThinkingPart(restoredPart as Record)); 252 | continue; 253 | } 254 | } 255 | } 256 | 257 | // Drop unsigned/invalid thinking blocks. 258 | } 259 | 260 | return filtered; 261 | } 262 | 263 | /** 264 | * Filters out unsigned thinking blocks from contents (required by Claude API). 265 | * Attempts to restore signatures from cache for thinking blocks that lack valid signatures. 266 | * 267 | * @param contents - The contents array from the request 268 | * @param sessionId - Optional session ID for signature cache lookup 269 | * @param getCachedSignatureFn - Optional function to retrieve cached signatures 270 | */ 271 | export function filterUnsignedThinkingBlocks( 272 | contents: any[], 273 | sessionId?: string, 274 | getCachedSignatureFn?: (sessionId: string, text: string) => string | undefined, 275 | ): any[] { 276 | return contents.map((content: any) => { 277 | if (!content || typeof content !== "object") { 278 | return content; 279 | } 280 | 281 | // Gemini format: contents[].parts[] 282 | if (Array.isArray((content as any).parts)) { 283 | let filteredParts = filterContentArray((content as any).parts, sessionId, getCachedSignatureFn); 284 | 285 | // Remove trailing thinking blocks for model role (assistant equivalent in Gemini) 286 | if ((content as any).role === "model") { 287 | filteredParts = removeTrailingThinkingBlocks(filteredParts); 288 | } 289 | 290 | return { ...content, parts: filteredParts }; 291 | } 292 | 293 | // Some Anthropic-style payloads may appear here as contents[].content[] 294 | if (Array.isArray((content as any).content)) { 295 | let filteredContent = filterContentArray((content as any).content, sessionId, getCachedSignatureFn); 296 | 297 | // Claude API requires assistant messages don't end with thinking blocks 298 | if ((content as any).role === "assistant") { 299 | filteredContent = removeTrailingThinkingBlocks(filteredContent); 300 | } 301 | 302 | return { ...content, content: filteredContent }; 303 | } 304 | 305 | return content; 306 | }); 307 | } 308 | 309 | /** 310 | * Filters thinking blocks from Anthropic-style messages[] payloads. 311 | */ 312 | export function filterMessagesThinkingBlocks( 313 | messages: any[], 314 | sessionId?: string, 315 | getCachedSignatureFn?: (sessionId: string, text: string) => string | undefined, 316 | ): any[] { 317 | return messages.map((message: any) => { 318 | if (!message || typeof message !== "object") { 319 | return message; 320 | } 321 | 322 | if (Array.isArray((message as any).content)) { 323 | let filteredContent = filterContentArray((message as any).content, sessionId, getCachedSignatureFn); 324 | 325 | // Claude API requires assistant messages don't end with thinking blocks 326 | if ((message as any).role === "assistant") { 327 | filteredContent = removeTrailingThinkingBlocks(filteredContent); 328 | } 329 | 330 | return { ...message, content: filteredContent }; 331 | } 332 | 333 | return message; 334 | }); 335 | } 336 | 337 | /** 338 | * Transforms Gemini-style thought parts (thought: true) and Anthropic-style 339 | * thinking parts (type: "thinking") to reasoning format. 340 | * Claude responses through Antigravity may use candidates structure with Anthropic-style parts. 341 | */ 342 | function transformGeminiCandidate(candidate: any): any { 343 | if (!candidate || typeof candidate !== "object") { 344 | return candidate; 345 | } 346 | 347 | const content = candidate.content; 348 | if (!content || typeof content !== "object" || !Array.isArray(content.parts)) { 349 | return candidate; 350 | } 351 | 352 | const thinkingTexts: string[] = []; 353 | const transformedParts = content.parts.map((part: any) => { 354 | if (!part || typeof part !== "object") { 355 | return part; 356 | } 357 | 358 | // Handle Gemini-style: thought: true 359 | if (part.thought === true) { 360 | thinkingTexts.push(part.text || ""); 361 | return { ...part, type: "reasoning" }; 362 | } 363 | 364 | // Handle Anthropic-style in candidates: type: "thinking" 365 | if (part.type === "thinking") { 366 | const thinkingText = part.thinking || part.text || ""; 367 | thinkingTexts.push(thinkingText); 368 | return { 369 | ...part, 370 | type: "reasoning", 371 | text: thinkingText, 372 | thought: true, 373 | }; 374 | } 375 | 376 | return part; 377 | }); 378 | 379 | return { 380 | ...candidate, 381 | content: { ...content, parts: transformedParts }, 382 | ...(thinkingTexts.length > 0 ? { reasoning_content: thinkingTexts.join("\n\n") } : {}), 383 | }; 384 | } 385 | 386 | /** 387 | * Transforms thinking/reasoning content in response parts to OpenCode's expected format. 388 | * Handles both Gemini-style (thought: true) and Anthropic-style (type: "thinking") formats. 389 | * Also extracts reasoning_content for Anthropic-style responses. 390 | */ 391 | export function transformThinkingParts(response: unknown): unknown { 392 | if (!response || typeof response !== "object") { 393 | return response; 394 | } 395 | 396 | const resp = response as Record; 397 | const result: Record = { ...resp }; 398 | const reasoningTexts: string[] = []; 399 | 400 | // Handle Anthropic-style content array (type: "thinking") 401 | if (Array.isArray(resp.content)) { 402 | const transformedContent: any[] = []; 403 | for (const block of resp.content) { 404 | if (block && typeof block === "object" && (block as any).type === "thinking") { 405 | const thinkingText = (block as any).thinking || (block as any).text || ""; 406 | reasoningTexts.push(thinkingText); 407 | transformedContent.push({ 408 | ...block, 409 | type: "reasoning", 410 | text: thinkingText, 411 | thought: true, 412 | }); 413 | } else { 414 | transformedContent.push(block); 415 | } 416 | } 417 | result.content = transformedContent; 418 | } 419 | 420 | // Handle Gemini-style candidates array 421 | if (Array.isArray(resp.candidates)) { 422 | result.candidates = resp.candidates.map(transformGeminiCandidate); 423 | } 424 | 425 | // Add reasoning_content if we found any thinking blocks (for Anthropic-style) 426 | if (reasoningTexts.length > 0 && !result.reasoning_content) { 427 | result.reasoning_content = reasoningTexts.join("\n\n"); 428 | } 429 | 430 | return result; 431 | } 432 | 433 | /** 434 | * Ensures thinkingConfig is valid: includeThoughts only allowed when budget > 0. 435 | */ 436 | export function normalizeThinkingConfig(config: unknown): ThinkingConfig | undefined { 437 | if (!config || typeof config !== "object") { 438 | return undefined; 439 | } 440 | 441 | const record = config as Record; 442 | const budgetRaw = record.thinkingBudget ?? record.thinking_budget; 443 | const includeRaw = record.includeThoughts ?? record.include_thoughts; 444 | 445 | const thinkingBudget = typeof budgetRaw === "number" && Number.isFinite(budgetRaw) ? budgetRaw : undefined; 446 | const includeThoughts = typeof includeRaw === "boolean" ? includeRaw : undefined; 447 | 448 | const enableThinking = thinkingBudget !== undefined && thinkingBudget > 0; 449 | const finalInclude = enableThinking ? includeThoughts ?? false : false; 450 | 451 | if (!enableThinking && finalInclude === false && thinkingBudget === undefined && includeThoughts === undefined) { 452 | return undefined; 453 | } 454 | 455 | const normalized: ThinkingConfig = {}; 456 | if (thinkingBudget !== undefined) { 457 | normalized.thinkingBudget = thinkingBudget; 458 | } 459 | if (finalInclude !== undefined) { 460 | normalized.includeThoughts = finalInclude; 461 | } 462 | return normalized; 463 | } 464 | 465 | /** 466 | * Parses an Antigravity API body; handles array-wrapped responses the API sometimes returns. 467 | */ 468 | export function parseAntigravityApiBody(rawText: string): AntigravityApiBody | null { 469 | try { 470 | const parsed = JSON.parse(rawText); 471 | if (Array.isArray(parsed)) { 472 | const firstObject = parsed.find((item: unknown) => typeof item === "object" && item !== null); 473 | if (firstObject && typeof firstObject === "object") { 474 | return firstObject as AntigravityApiBody; 475 | } 476 | return null; 477 | } 478 | 479 | if (parsed && typeof parsed === "object") { 480 | return parsed as AntigravityApiBody; 481 | } 482 | 483 | return null; 484 | } catch { 485 | return null; 486 | } 487 | } 488 | 489 | /** 490 | * Extracts usageMetadata from a response object, guarding types. 491 | */ 492 | export function extractUsageMetadata(body: AntigravityApiBody): AntigravityUsageMetadata | null { 493 | const usage = (body.response && typeof body.response === "object" 494 | ? (body.response as { usageMetadata?: unknown }).usageMetadata 495 | : undefined) as AntigravityUsageMetadata | undefined; 496 | 497 | if (!usage || typeof usage !== "object") { 498 | return null; 499 | } 500 | 501 | const asRecord = usage as Record; 502 | const toNumber = (value: unknown): number | undefined => 503 | typeof value === "number" && Number.isFinite(value) ? value : undefined; 504 | 505 | return { 506 | totalTokenCount: toNumber(asRecord.totalTokenCount), 507 | promptTokenCount: toNumber(asRecord.promptTokenCount), 508 | candidatesTokenCount: toNumber(asRecord.candidatesTokenCount), 509 | cachedContentTokenCount: toNumber(asRecord.cachedContentTokenCount), 510 | }; 511 | } 512 | 513 | /** 514 | * Walks SSE lines to find a usage-bearing response chunk. 515 | */ 516 | export function extractUsageFromSsePayload(payload: string): AntigravityUsageMetadata | null { 517 | const lines = payload.split("\n"); 518 | for (const line of lines) { 519 | if (!line.startsWith("data:")) { 520 | continue; 521 | } 522 | const jsonText = line.slice(5).trim(); 523 | if (!jsonText) { 524 | continue; 525 | } 526 | try { 527 | const parsed = JSON.parse(jsonText); 528 | if (parsed && typeof parsed === "object") { 529 | const usage = extractUsageMetadata({ response: (parsed as Record).response }); 530 | if (usage) { 531 | return usage; 532 | } 533 | } 534 | } catch { 535 | continue; 536 | } 537 | } 538 | return null; 539 | } 540 | 541 | /** 542 | * Enhances 404 errors for Antigravity models with a direct preview-access message. 543 | */ 544 | export function rewriteAntigravityPreviewAccessError( 545 | body: AntigravityApiBody, 546 | status: number, 547 | requestedModel?: string, 548 | ): AntigravityApiBody | null { 549 | if (!needsPreviewAccessOverride(status, body, requestedModel)) { 550 | return null; 551 | } 552 | 553 | const error: AntigravityApiError = body.error ?? {}; 554 | const trimmedMessage = typeof error.message === "string" ? error.message.trim() : ""; 555 | const messagePrefix = trimmedMessage.length > 0 556 | ? trimmedMessage 557 | : "Antigravity preview features are not enabled for this account."; 558 | const enhancedMessage = `${messagePrefix} Request preview access at ${ANTIGRAVITY_PREVIEW_LINK} before using this model.`; 559 | 560 | return { 561 | ...body, 562 | error: { 563 | ...error, 564 | message: enhancedMessage, 565 | }, 566 | }; 567 | } 568 | 569 | function needsPreviewAccessOverride( 570 | status: number, 571 | body: AntigravityApiBody, 572 | requestedModel?: string, 573 | ): boolean { 574 | if (status !== 404) { 575 | return false; 576 | } 577 | 578 | if (isAntigravityModel(requestedModel)) { 579 | return true; 580 | } 581 | 582 | const errorMessage = typeof body.error?.message === "string" ? body.error.message : ""; 583 | return isAntigravityModel(errorMessage); 584 | } 585 | 586 | function isAntigravityModel(target?: string): boolean { 587 | if (!target) { 588 | return false; 589 | } 590 | 591 | // Check for Antigravity models instead of Gemini 3 592 | return /antigravity/i.test(target) || /opus/i.test(target) || /claude/i.test(target); 593 | } 594 | -------------------------------------------------------------------------------- /src/plugin/request-helpers.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from "vitest"; 2 | 3 | import { 4 | isThinkingCapableModel, 5 | extractThinkingConfig, 6 | resolveThinkingConfig, 7 | filterUnsignedThinkingBlocks, 8 | filterMessagesThinkingBlocks, 9 | transformThinkingParts, 10 | normalizeThinkingConfig, 11 | parseAntigravityApiBody, 12 | extractUsageMetadata, 13 | extractUsageFromSsePayload, 14 | rewriteAntigravityPreviewAccessError, 15 | DEFAULT_THINKING_BUDGET, 16 | } from "./request-helpers"; 17 | 18 | describe("sanitizeThinkingPart (covered via filtering)", () => { 19 | it("extracts wrapped text and strips SDK fields for Gemini-style thought blocks", () => { 20 | const validSignature = "s".repeat(60); 21 | 22 | const contents = [ 23 | { 24 | role: "model", 25 | parts: [ 26 | { 27 | thought: true, 28 | text: { 29 | text: "wrapped thought", 30 | cache_control: { type: "ephemeral" }, 31 | providerOptions: { injected: true }, 32 | }, 33 | thoughtSignature: validSignature, 34 | cache_control: { type: "ephemeral" }, 35 | providerOptions: { injected: true }, 36 | }, 37 | ], 38 | }, 39 | ]; 40 | 41 | const result = filterUnsignedThinkingBlocks(contents) as any; 42 | expect(result[0].parts).toHaveLength(1); 43 | expect(result[0].parts[0]).toEqual({ 44 | thought: true, 45 | text: "wrapped thought", 46 | thoughtSignature: validSignature, 47 | }); 48 | 49 | // Ensure injected fields are removed 50 | expect(result[0].parts[0].cache_control).toBeUndefined(); 51 | expect(result[0].parts[0].providerOptions).toBeUndefined(); 52 | }); 53 | 54 | it("extracts wrapped thinking text and strips SDK fields for Anthropic-style thinking blocks", () => { 55 | const validSignature = "a".repeat(60); 56 | 57 | const contents = [ 58 | { 59 | role: "model", 60 | parts: [ 61 | { 62 | type: "thinking", 63 | thinking: { 64 | text: "wrapped thinking", 65 | cache_control: { type: "ephemeral" }, 66 | providerOptions: { injected: true }, 67 | }, 68 | signature: validSignature, 69 | cache_control: { type: "ephemeral" }, 70 | providerOptions: { injected: true }, 71 | }, 72 | ], 73 | }, 74 | ]; 75 | 76 | const result = filterUnsignedThinkingBlocks(contents) as any; 77 | expect(result[0].parts).toHaveLength(1); 78 | expect(result[0].parts[0]).toEqual({ 79 | type: "thinking", 80 | thinking: "wrapped thinking", 81 | signature: validSignature, 82 | }); 83 | }); 84 | 85 | it("preserves signatures while dropping cache_control/providerOptions during signature restoration", () => { 86 | const cachedSignature = "c".repeat(60); 87 | const getCachedSignatureFn = (_sessionId: string, _text: string) => cachedSignature; 88 | 89 | const messages = [ 90 | { 91 | role: "assistant", 92 | content: [ 93 | { 94 | type: "thinking", 95 | thinking: { 96 | thinking: "restore me", 97 | cache_control: { type: "ephemeral" }, 98 | }, 99 | // no signature present (forces restore) 100 | providerOptions: { injected: true }, 101 | }, 102 | { type: "text", text: "visible" }, 103 | ], 104 | }, 105 | ]; 106 | 107 | const result = filterMessagesThinkingBlocks(messages, "session-1", getCachedSignatureFn) as any; 108 | expect(result[0].content[0]).toEqual({ 109 | type: "thinking", 110 | thinking: "restore me", 111 | signature: cachedSignature, 112 | }); 113 | }); 114 | 115 | it("falls back to recursive stripping for signed reasoning blocks and removes nested SDK fields", () => { 116 | const validSignature = "z".repeat(60); 117 | 118 | const contents = [ 119 | { 120 | role: "model", 121 | parts: [ 122 | { 123 | type: "reasoning", 124 | signature: validSignature, 125 | cache_control: { type: "ephemeral" }, 126 | providerOptions: { injected: true }, 127 | meta: { 128 | keep: true, 129 | cache_control: { nested: true }, 130 | arr: [ 131 | { providerOptions: { nested: true }, keep: 1 }, 132 | { cache_control: { nested: true }, keep: 2 }, 133 | ], 134 | }, 135 | }, 136 | { type: "text", text: "visible" }, 137 | ], 138 | }, 139 | ]; 140 | 141 | const result = filterUnsignedThinkingBlocks(contents) as any; 142 | expect(result[0].parts[0]).toEqual({ 143 | type: "reasoning", 144 | signature: validSignature, 145 | meta: { 146 | keep: true, 147 | arr: [ 148 | { keep: 1 }, 149 | { keep: 2 }, 150 | ], 151 | }, 152 | }); 153 | }); 154 | }); 155 | 156 | describe("isThinkingCapableModel", () => { 157 | it("returns true for models with 'thinking' in name", () => { 158 | expect(isThinkingCapableModel("claude-thinking")).toBe(true); 159 | expect(isThinkingCapableModel("CLAUDE-THINKING-4")).toBe(true); 160 | expect(isThinkingCapableModel("model-thinking-v1")).toBe(true); 161 | }); 162 | 163 | it("returns true for models with 'gemini-3' in name", () => { 164 | expect(isThinkingCapableModel("gemini-3-pro")).toBe(true); 165 | expect(isThinkingCapableModel("GEMINI-3-flash")).toBe(true); 166 | expect(isThinkingCapableModel("gemini-3")).toBe(true); 167 | }); 168 | 169 | it("returns true for models with 'opus' in name", () => { 170 | expect(isThinkingCapableModel("claude-opus")).toBe(true); 171 | expect(isThinkingCapableModel("claude-4-opus")).toBe(true); 172 | expect(isThinkingCapableModel("OPUS")).toBe(true); 173 | }); 174 | 175 | it("returns false for non-thinking models", () => { 176 | expect(isThinkingCapableModel("claude-sonnet")).toBe(false); 177 | expect(isThinkingCapableModel("gemini-2-pro")).toBe(false); 178 | expect(isThinkingCapableModel("gpt-4")).toBe(false); 179 | }); 180 | }); 181 | 182 | describe("extractThinkingConfig", () => { 183 | it("extracts thinkingConfig from generationConfig", () => { 184 | const result = extractThinkingConfig( 185 | {}, 186 | { thinkingConfig: { includeThoughts: true, thinkingBudget: 8000 } }, 187 | undefined, 188 | ); 189 | expect(result).toEqual({ includeThoughts: true, thinkingBudget: 8000 }); 190 | }); 191 | 192 | it("extracts thinkingConfig from extra_body", () => { 193 | const result = extractThinkingConfig( 194 | {}, 195 | undefined, 196 | { thinkingConfig: { includeThoughts: true, thinkingBudget: 4000 } }, 197 | ); 198 | expect(result).toEqual({ includeThoughts: true, thinkingBudget: 4000 }); 199 | }); 200 | 201 | it("extracts thinkingConfig from requestPayload directly", () => { 202 | const result = extractThinkingConfig( 203 | { thinkingConfig: { includeThoughts: false, thinkingBudget: 2000 } }, 204 | undefined, 205 | undefined, 206 | ); 207 | expect(result).toEqual({ includeThoughts: false, thinkingBudget: 2000 }); 208 | }); 209 | 210 | it("prioritizes generationConfig over extra_body", () => { 211 | const result = extractThinkingConfig( 212 | {}, 213 | { thinkingConfig: { includeThoughts: true, thinkingBudget: 8000 } }, 214 | { thinkingConfig: { includeThoughts: false, thinkingBudget: 4000 } }, 215 | ); 216 | expect(result).toEqual({ includeThoughts: true, thinkingBudget: 8000 }); 217 | }); 218 | 219 | it("converts Anthropic-style thinking config", () => { 220 | const result = extractThinkingConfig( 221 | { thinking: { type: "enabled", budgetTokens: 10000 } }, 222 | undefined, 223 | undefined, 224 | ); 225 | expect(result).toEqual({ includeThoughts: true, thinkingBudget: 10000 }); 226 | }); 227 | 228 | it("uses default budget for Anthropic-style without budgetTokens", () => { 229 | const result = extractThinkingConfig( 230 | { thinking: { type: "enabled" } }, 231 | undefined, 232 | undefined, 233 | ); 234 | expect(result).toEqual({ includeThoughts: true, thinkingBudget: DEFAULT_THINKING_BUDGET }); 235 | }); 236 | 237 | it("returns undefined when no config found", () => { 238 | expect(extractThinkingConfig({}, undefined, undefined)).toBeUndefined(); 239 | }); 240 | 241 | it("uses default budget when thinkingBudget not specified", () => { 242 | const result = extractThinkingConfig( 243 | {}, 244 | { thinkingConfig: { includeThoughts: true } }, 245 | undefined, 246 | ); 247 | expect(result).toEqual({ includeThoughts: true, thinkingBudget: DEFAULT_THINKING_BUDGET }); 248 | }); 249 | }); 250 | 251 | describe("resolveThinkingConfig", () => { 252 | it("keeps thinking enabled for Claude models with assistant history", () => { 253 | const result = resolveThinkingConfig( 254 | { includeThoughts: true, thinkingBudget: 8000 }, 255 | true, // isThinkingModel 256 | true, // isClaudeModel 257 | true, // hasAssistantHistory 258 | ); 259 | expect(result).toEqual({ includeThoughts: true, thinkingBudget: 8000 }); 260 | }); 261 | 262 | it("enables thinking for thinking-capable models without user config", () => { 263 | const result = resolveThinkingConfig( 264 | undefined, 265 | true, // isThinkingModel 266 | false, // isClaudeModel 267 | false, // hasAssistantHistory 268 | ); 269 | expect(result).toEqual({ includeThoughts: true, thinkingBudget: DEFAULT_THINKING_BUDGET }); 270 | }); 271 | 272 | it("respects user config for non-Claude models", () => { 273 | const userConfig = { includeThoughts: false, thinkingBudget: 5000 }; 274 | const result = resolveThinkingConfig( 275 | userConfig, 276 | true, 277 | false, 278 | false, 279 | ); 280 | expect(result).toEqual(userConfig); 281 | }); 282 | 283 | it("returns user config for Claude without history", () => { 284 | const userConfig = { includeThoughts: true, thinkingBudget: 8000 }; 285 | const result = resolveThinkingConfig( 286 | userConfig, 287 | true, 288 | true, // isClaudeModel 289 | false, // no history 290 | ); 291 | expect(result).toEqual(userConfig); 292 | }); 293 | 294 | it("returns undefined for non-thinking model without user config", () => { 295 | const result = resolveThinkingConfig( 296 | undefined, 297 | false, // not thinking model 298 | false, 299 | false, 300 | ); 301 | expect(result).toBeUndefined(); 302 | }); 303 | }); 304 | 305 | describe("filterUnsignedThinkingBlocks", () => { 306 | it("filters out unsigned thinking parts", () => { 307 | const contents = [ 308 | { 309 | role: "model", 310 | parts: [ 311 | { type: "thinking", text: "thinking without signature" }, 312 | { type: "text", text: "visible text" }, 313 | ], 314 | }, 315 | ]; 316 | const result = filterUnsignedThinkingBlocks(contents); 317 | expect(result[0].parts).toHaveLength(1); 318 | expect(result[0].parts[0].type).toBe("text"); 319 | }); 320 | 321 | it("keeps signed thinking parts with valid signatures", () => { 322 | const validSignature = "a".repeat(60); 323 | const contents = [ 324 | { 325 | role: "model", 326 | parts: [ 327 | { type: "thinking", text: "thinking with signature", signature: validSignature }, 328 | { type: "text", text: "visible text" }, 329 | ], 330 | }, 331 | ]; 332 | const result = filterUnsignedThinkingBlocks(contents); 333 | expect(result[0].parts).toHaveLength(2); 334 | expect(result[0].parts[0].signature).toBe(validSignature); 335 | }); 336 | 337 | it("filters thinking parts with short signatures", () => { 338 | const contents = [ 339 | { 340 | role: "model", 341 | parts: [ 342 | { type: "thinking", text: "thinking with short signature", signature: "sig123" }, 343 | { type: "text", text: "visible text" }, 344 | ], 345 | }, 346 | ]; 347 | const result = filterUnsignedThinkingBlocks(contents); 348 | expect(result[0].parts).toHaveLength(1); 349 | expect(result[0].parts[0].type).toBe("text"); 350 | }); 351 | 352 | it("handles Gemini-style thought parts with valid signatures", () => { 353 | const validSignature = "b".repeat(55); 354 | const contents = [ 355 | { 356 | role: "model", 357 | parts: [ 358 | { thought: true, text: "no signature" }, 359 | { thought: true, text: "has signature", thoughtSignature: validSignature }, 360 | ], 361 | }, 362 | ]; 363 | const result = filterUnsignedThinkingBlocks(contents); 364 | expect(result[0].parts).toHaveLength(1); 365 | expect(result[0].parts[0].thoughtSignature).toBe(validSignature); 366 | }); 367 | 368 | it("filters Gemini-style thought parts with short signatures", () => { 369 | const contents = [ 370 | { 371 | role: "model", 372 | parts: [ 373 | { thought: true, text: "has short signature", thoughtSignature: "sig" }, 374 | ], 375 | }, 376 | ]; 377 | const result = filterUnsignedThinkingBlocks(contents); 378 | expect(result[0].parts).toHaveLength(0); 379 | }); 380 | 381 | it("preserves non-thinking parts", () => { 382 | const contents = [ 383 | { 384 | role: "user", 385 | parts: [{ text: "hello" }], 386 | }, 387 | ]; 388 | const result = filterUnsignedThinkingBlocks(contents); 389 | expect(result).toEqual(contents); 390 | }); 391 | 392 | it("handles empty parts array", () => { 393 | const contents = [{ role: "model", parts: [] }]; 394 | const result = filterUnsignedThinkingBlocks(contents); 395 | expect(result[0].parts).toEqual([]); 396 | }); 397 | 398 | it("handles missing parts", () => { 399 | const contents = [{ role: "model" }]; 400 | const result = filterUnsignedThinkingBlocks(contents); 401 | expect(result).toEqual(contents); 402 | }); 403 | }); 404 | 405 | describe("filterMessagesThinkingBlocks", () => { 406 | it("filters out unsigned thinking blocks in messages[].content", () => { 407 | const messages = [ 408 | { 409 | role: "assistant", 410 | content: [ 411 | { type: "thinking", thinking: "no signature" }, 412 | { type: "text", text: "visible" }, 413 | ], 414 | }, 415 | ]; 416 | 417 | const result = filterMessagesThinkingBlocks(messages) as any; 418 | expect(result[0].content).toHaveLength(1); 419 | expect(result[0].content[0].type).toBe("text"); 420 | }); 421 | 422 | it("keeps signed thinking blocks with valid signatures and sanitizes injected fields", () => { 423 | const validSignature = "a".repeat(60); 424 | const messages = [ 425 | { 426 | role: "assistant", 427 | content: [ 428 | { 429 | type: "thinking", 430 | thinking: { text: "wrapped", cache_control: { type: "ephemeral" } }, 431 | signature: validSignature, 432 | cache_control: { type: "ephemeral" }, 433 | providerOptions: { injected: true }, 434 | }, 435 | { type: "text", text: "visible" }, 436 | ], 437 | }, 438 | ]; 439 | 440 | const result = filterMessagesThinkingBlocks(messages) as any; 441 | expect(result[0].content[0]).toEqual({ 442 | type: "thinking", 443 | thinking: "wrapped", 444 | signature: validSignature, 445 | }); 446 | }); 447 | 448 | it("filters thinking blocks with short signatures", () => { 449 | const messages = [ 450 | { 451 | role: "assistant", 452 | content: [ 453 | { type: "thinking", thinking: "short sig", signature: "sig123" }, 454 | { type: "text", text: "visible" }, 455 | ], 456 | }, 457 | ]; 458 | 459 | const result = filterMessagesThinkingBlocks(messages) as any; 460 | expect(result[0].content).toEqual([{ type: "text", text: "visible" }]); 461 | }); 462 | 463 | it("restores a missing signature from cache and preserves it after sanitization", () => { 464 | const cachedSignature = "c".repeat(60); 465 | const getCachedSignatureFn = (_sessionId: string, _text: string) => cachedSignature; 466 | 467 | const messages = [ 468 | { 469 | role: "assistant", 470 | content: [ 471 | { 472 | type: "thinking", 473 | thinking: { thinking: "restore me", providerOptions: { injected: true } }, 474 | // no signature present (forces restore) 475 | cache_control: { type: "ephemeral" }, 476 | }, 477 | { type: "text", text: "visible" }, 478 | ], 479 | }, 480 | ]; 481 | 482 | const result = filterMessagesThinkingBlocks(messages, "session-1", getCachedSignatureFn) as any; 483 | expect(result[0].content[0]).toEqual({ 484 | type: "thinking", 485 | thinking: "restore me", 486 | signature: cachedSignature, 487 | }); 488 | }); 489 | 490 | it("handles Gemini-style thought blocks inside messages content", () => { 491 | const validSignature = "b".repeat(60); 492 | const messages = [ 493 | { 494 | role: "assistant", 495 | content: [ 496 | { 497 | thought: true, 498 | text: { text: "wrapped thought", cache_control: { type: "ephemeral" } }, 499 | thoughtSignature: validSignature, 500 | providerOptions: { injected: true }, 501 | }, 502 | { type: "text", text: "visible" }, 503 | ], 504 | }, 505 | ]; 506 | 507 | const result = filterMessagesThinkingBlocks(messages) as any; 508 | expect(result[0].content[0]).toEqual({ 509 | thought: true, 510 | text: "wrapped thought", 511 | thoughtSignature: validSignature, 512 | }); 513 | }); 514 | 515 | it("preserves non-thinking blocks and returns message unchanged when content is missing", () => { 516 | const messages: any[] = [ 517 | { role: "assistant", content: [{ type: "text", text: "hello" }] }, 518 | { role: "assistant" }, 519 | ]; 520 | 521 | const result = filterMessagesThinkingBlocks(messages) as any; 522 | expect(result[0]).toEqual(messages[0]); 523 | expect(result[1]).toEqual(messages[1]); 524 | }); 525 | 526 | it("handles non-object messages gracefully", () => { 527 | const messages: any[] = [null, "string", 123, { role: "assistant", content: [] }]; 528 | const result = filterMessagesThinkingBlocks(messages) as any; 529 | expect(result).toEqual(messages); 530 | }); 531 | }); 532 | 533 | describe("transformThinkingParts", () => { 534 | it("transforms Anthropic-style thinking blocks to reasoning", () => { 535 | const response = { 536 | content: [ 537 | { type: "thinking", thinking: "my thoughts" }, 538 | { type: "text", text: "visible" }, 539 | ], 540 | }; 541 | const result = transformThinkingParts(response) as any; 542 | expect(result.content[0].type).toBe("reasoning"); 543 | expect(result.content[0].thought).toBe(true); 544 | expect(result.reasoning_content).toBe("my thoughts"); 545 | }); 546 | 547 | it("transforms Gemini-style candidates", () => { 548 | const response = { 549 | candidates: [ 550 | { 551 | content: { 552 | parts: [ 553 | { thought: true, text: "thinking here" }, 554 | { text: "output" }, 555 | ], 556 | }, 557 | }, 558 | ], 559 | }; 560 | const result = transformThinkingParts(response) as any; 561 | expect(result.candidates[0].content.parts[0].type).toBe("reasoning"); 562 | expect(result.candidates[0].reasoning_content).toBe("thinking here"); 563 | }); 564 | 565 | it("handles non-object input", () => { 566 | expect(transformThinkingParts(null)).toBeNull(); 567 | expect(transformThinkingParts(undefined)).toBeUndefined(); 568 | expect(transformThinkingParts("string")).toBe("string"); 569 | }); 570 | 571 | it("preserves other response properties", () => { 572 | const response = { 573 | content: [], 574 | id: "resp-123", 575 | model: "claude-4", 576 | }; 577 | const result = transformThinkingParts(response) as any; 578 | expect(result.id).toBe("resp-123"); 579 | expect(result.model).toBe("claude-4"); 580 | }); 581 | }); 582 | 583 | describe("normalizeThinkingConfig", () => { 584 | it("returns undefined for non-object input", () => { 585 | expect(normalizeThinkingConfig(null)).toBeUndefined(); 586 | expect(normalizeThinkingConfig(undefined)).toBeUndefined(); 587 | expect(normalizeThinkingConfig("string")).toBeUndefined(); 588 | }); 589 | 590 | it("normalizes valid config", () => { 591 | const result = normalizeThinkingConfig({ 592 | thinkingBudget: 8000, 593 | includeThoughts: true, 594 | }); 595 | expect(result).toEqual({ 596 | thinkingBudget: 8000, 597 | includeThoughts: true, 598 | }); 599 | }); 600 | 601 | it("handles snake_case property names", () => { 602 | const result = normalizeThinkingConfig({ 603 | thinking_budget: 4000, 604 | include_thoughts: true, 605 | }); 606 | expect(result).toEqual({ 607 | thinkingBudget: 4000, 608 | includeThoughts: true, 609 | }); 610 | }); 611 | 612 | it("disables includeThoughts when budget is 0", () => { 613 | const result = normalizeThinkingConfig({ 614 | thinkingBudget: 0, 615 | includeThoughts: true, 616 | }); 617 | expect(result?.includeThoughts).toBe(false); 618 | }); 619 | 620 | it("returns undefined when both values are absent/undefined", () => { 621 | const result = normalizeThinkingConfig({}); 622 | expect(result).toBeUndefined(); 623 | }); 624 | 625 | it("handles non-finite budget values", () => { 626 | const result = normalizeThinkingConfig({ 627 | thinkingBudget: Infinity, 628 | includeThoughts: true, 629 | }); 630 | // When budget is non-finite (undefined), includeThoughts is forced to false 631 | expect(result).toEqual({ includeThoughts: false }); 632 | }); 633 | }); 634 | 635 | describe("parseAntigravityApiBody", () => { 636 | it("parses valid JSON object", () => { 637 | const result = parseAntigravityApiBody('{"response": {"text": "hello"}}'); 638 | expect(result).toEqual({ response: { text: "hello" } }); 639 | }); 640 | 641 | it("extracts first object from array", () => { 642 | const result = parseAntigravityApiBody('[{"response": "first"}, {"response": "second"}]'); 643 | expect(result).toEqual({ response: "first" }); 644 | }); 645 | 646 | it("returns null for invalid JSON", () => { 647 | expect(parseAntigravityApiBody("not json")).toBeNull(); 648 | }); 649 | 650 | it("returns null for empty array", () => { 651 | expect(parseAntigravityApiBody("[]")).toBeNull(); 652 | }); 653 | 654 | it("returns null for primitive values", () => { 655 | expect(parseAntigravityApiBody('"string"')).toBeNull(); 656 | expect(parseAntigravityApiBody("123")).toBeNull(); 657 | }); 658 | 659 | it("handles array with null values", () => { 660 | const result = parseAntigravityApiBody('[null, {"valid": true}]'); 661 | expect(result).toEqual({ valid: true }); 662 | }); 663 | }); 664 | 665 | describe("extractUsageMetadata", () => { 666 | it("extracts usage from response.usageMetadata", () => { 667 | const body = { 668 | response: { 669 | usageMetadata: { 670 | totalTokenCount: 1000, 671 | promptTokenCount: 500, 672 | candidatesTokenCount: 500, 673 | cachedContentTokenCount: 100, 674 | }, 675 | }, 676 | }; 677 | const result = extractUsageMetadata(body); 678 | expect(result).toEqual({ 679 | totalTokenCount: 1000, 680 | promptTokenCount: 500, 681 | candidatesTokenCount: 500, 682 | cachedContentTokenCount: 100, 683 | }); 684 | }); 685 | 686 | it("returns null when no usageMetadata", () => { 687 | expect(extractUsageMetadata({ response: {} })).toBeNull(); 688 | expect(extractUsageMetadata({})).toBeNull(); 689 | }); 690 | 691 | it("handles partial usage data", () => { 692 | const body = { 693 | response: { 694 | usageMetadata: { 695 | totalTokenCount: 1000, 696 | }, 697 | }, 698 | }; 699 | const result = extractUsageMetadata(body); 700 | expect(result).toEqual({ 701 | totalTokenCount: 1000, 702 | promptTokenCount: undefined, 703 | candidatesTokenCount: undefined, 704 | cachedContentTokenCount: undefined, 705 | }); 706 | }); 707 | 708 | it("filters non-finite numbers", () => { 709 | const body = { 710 | response: { 711 | usageMetadata: { 712 | totalTokenCount: Infinity, 713 | promptTokenCount: NaN, 714 | candidatesTokenCount: 100, 715 | }, 716 | }, 717 | }; 718 | const result = extractUsageMetadata(body); 719 | expect(result?.totalTokenCount).toBeUndefined(); 720 | expect(result?.promptTokenCount).toBeUndefined(); 721 | expect(result?.candidatesTokenCount).toBe(100); 722 | }); 723 | }); 724 | 725 | describe("extractUsageFromSsePayload", () => { 726 | it("extracts usage from SSE data line", () => { 727 | const payload = `data: {"response": {"usageMetadata": {"totalTokenCount": 500}}}`; 728 | const result = extractUsageFromSsePayload(payload); 729 | expect(result?.totalTokenCount).toBe(500); 730 | }); 731 | 732 | it("handles multiple SSE lines", () => { 733 | const payload = `data: {"response": {}} 734 | data: {"response": {"usageMetadata": {"totalTokenCount": 1000}}}`; 735 | const result = extractUsageFromSsePayload(payload); 736 | expect(result?.totalTokenCount).toBe(1000); 737 | }); 738 | 739 | it("returns null when no usage found", () => { 740 | const payload = `data: {"response": {"text": "hello"}}`; 741 | const result = extractUsageFromSsePayload(payload); 742 | expect(result).toBeNull(); 743 | }); 744 | 745 | it("ignores non-data lines", () => { 746 | const payload = `: keepalive 747 | event: message 748 | data: {"response": {"usageMetadata": {"totalTokenCount": 200}}}`; 749 | const result = extractUsageFromSsePayload(payload); 750 | expect(result?.totalTokenCount).toBe(200); 751 | }); 752 | 753 | it("handles malformed JSON gracefully", () => { 754 | const payload = `data: not json 755 | data: {"response": {"usageMetadata": {"totalTokenCount": 300}}}`; 756 | const result = extractUsageFromSsePayload(payload); 757 | expect(result?.totalTokenCount).toBe(300); 758 | }); 759 | }); 760 | 761 | describe("rewriteAntigravityPreviewAccessError", () => { 762 | it("returns null for non-404 status", () => { 763 | const body = { error: { message: "Not found" } }; 764 | expect(rewriteAntigravityPreviewAccessError(body, 400)).toBeNull(); 765 | expect(rewriteAntigravityPreviewAccessError(body, 500)).toBeNull(); 766 | }); 767 | 768 | it("rewrites error for Antigravity model on 404", () => { 769 | const body = { error: { message: "Model not found" } }; 770 | const result = rewriteAntigravityPreviewAccessError(body, 404, "claude-opus"); 771 | expect(result?.error?.message).toContain("Model not found"); 772 | expect(result?.error?.message).toContain("preview access"); 773 | }); 774 | 775 | it("rewrites error when error message contains antigravity", () => { 776 | const body = { error: { message: "antigravity model unavailable" } }; 777 | const result = rewriteAntigravityPreviewAccessError(body, 404); 778 | expect(result?.error?.message).toContain("preview access"); 779 | }); 780 | 781 | it("returns null for 404 with non-antigravity model", () => { 782 | const body = { error: { message: "Model not found" } }; 783 | const result = rewriteAntigravityPreviewAccessError(body, 404, "gemini-pro"); 784 | expect(result).toBeNull(); 785 | }); 786 | 787 | it("provides default message when error message is empty", () => { 788 | const body = { error: { message: "" } }; 789 | const result = rewriteAntigravityPreviewAccessError(body, 404, "opus-model"); 790 | expect(result?.error?.message).toContain("Antigravity preview features are not enabled"); 791 | }); 792 | 793 | it("detects Claude models in requested model name", () => { 794 | const body = { error: {} }; 795 | const result = rewriteAntigravityPreviewAccessError(body, 404, "claude-3-sonnet"); 796 | expect(result?.error?.message).toContain("preview access"); 797 | }); 798 | }); 799 | --------------------------------------------------------------------------------