fix(antigravity-auth): address Oracle feedback - custom credentials, logging, docs

- Fix custom credentials to actually work in OAuth/refresh flows
  - oauth.ts: Add clientId/clientSecret parameters to buildAuthURL(), exchangeCode()
  - token.ts: Add clientId/clientSecret parameters to refreshAccessToken()
  - fetch.ts: Pass credentials to oauth/token functions
  - plugin.ts: Use closure cache for credentials, pass to all flows

- Unify console.* logging policy with ANTIGRAVITY_DEBUG guards
  - constants.ts: Document logging policy
  - tools.ts: Guard console.warn with ANTIGRAVITY_DEBUG
  - plugin.ts: Guard 4 console.error with ANTIGRAVITY_DEBUG

- Add explicit init.body type handling
  - fetch.ts: Check body type, pass-through non-string bodies
  - fetch.ts: Document body type assumption

- Document SSE buffering behavior
  - response.ts: Add warning that current implementation buffers
  - response.ts: Add TODO for future ReadableStream enhancement

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
This commit is contained in:
YeonGyu-Kim
2025-12-12 23:24:20 +09:00
parent 4b3b581901
commit d450c4f966
8 changed files with 137 additions and 79 deletions

View File

@@ -8,7 +8,7 @@
## 현재 진행 중인 작업 ## 현재 진행 중인 작업
**모든 작업 완료** - ✅ Phase 1-4 완료 (14 tasks) **모든 작업 완료** - ✅ Phase 1-4 완료 (14 tasks) + Oracle 문서화 이슈 수정
--- ---

View File

@@ -1,6 +1,20 @@
/** /**
* Antigravity OAuth configuration constants. * Antigravity OAuth configuration constants.
* Values sourced from cliproxyapi/sdk/auth/antigravity.go * Values sourced from cliproxyapi/sdk/auth/antigravity.go
*
* ## Logging Policy
*
* All console logging in antigravity modules follows a consistent policy:
*
* - **Debug logs**: Guard with `if (process.env.ANTIGRAVITY_DEBUG === "1")`
* - Includes: info messages, warnings, non-fatal errors
* - Enable debugging: `ANTIGRAVITY_DEBUG=1 opencode`
*
* - **Fatal errors**: None currently. All errors are handled by returning
* appropriate error responses to OpenCode's auth system.
*
* This policy ensures production silence while enabling verbose debugging
* when needed for troubleshooting OAuth flows.
*/ */
// OAuth 2.0 Client Credentials // OAuth 2.0 Client Credentials

View File

@@ -8,6 +8,12 @@
* - Applies response transformation (including thinking extraction) * - Applies response transformation (including thinking extraction)
* - Implements endpoint fallback (daily → autopush → prod) * - Implements endpoint fallback (daily → autopush → prod)
* *
* **Body Type Assumption:**
* This interceptor assumes `init.body` is a JSON string (OpenAI format).
* Non-string bodies (ReadableStream, Blob, FormData, URLSearchParams, etc.)
* are passed through unchanged to the original fetch to avoid breaking
* other requests that may not be OpenAI-format API calls.
*
* Debug logging available via ANTIGRAVITY_DEBUG=1 environment variable. * Debug logging available via ANTIGRAVITY_DEBUG=1 environment variable.
*/ */
@@ -59,9 +65,6 @@ function isRetryableError(status: number): boolean {
return false return false
} }
/**
* Attempt fetch with a single endpoint
*/
async function attemptFetch( async function attemptFetch(
endpoint: string, endpoint: string,
url: string, url: string,
@@ -69,43 +72,42 @@ async function attemptFetch(
accessToken: string, accessToken: string,
projectId: string, projectId: string,
modelName?: string modelName?: string
): Promise<Response | null> { ): Promise<Response | null | "pass-through"> {
debugLog(`Trying endpoint: ${endpoint}`) debugLog(`Trying endpoint: ${endpoint}`)
try { try {
// Parse request body if present const rawBody = init.body
let body: Record<string, unknown> = {}
if (init.body) { if (rawBody !== undefined && typeof rawBody !== "string") {
debugLog(`Non-string body detected (${typeof rawBody}), signaling pass-through`)
return "pass-through"
}
let parsedBody: Record<string, unknown> = {}
if (rawBody) {
try { try {
body = parsedBody = JSON.parse(rawBody) as Record<string, unknown>
typeof init.body === "string"
? (JSON.parse(init.body) as Record<string, unknown>)
: (init.body as unknown as Record<string, unknown>)
} catch { } catch {
// If body parsing fails, use empty object parsedBody = {}
body = {}
} }
} }
// Apply tool normalization if tools present if (parsedBody.tools && Array.isArray(parsedBody.tools)) {
if (body.tools && Array.isArray(body.tools)) { const normalizedTools = normalizeToolsForGemini(parsedBody.tools as OpenAITool[])
const normalizedTools = normalizeToolsForGemini(body.tools as OpenAITool[])
if (normalizedTools) { if (normalizedTools) {
body.tools = normalizedTools parsedBody.tools = normalizedTools
} }
} }
// Transform request
const transformed = transformRequest( const transformed = transformRequest(
url, url,
body, parsedBody,
accessToken, accessToken,
projectId, projectId,
modelName, modelName,
endpoint endpoint
) )
// Make the request
const response = await fetch(transformed.url, { const response = await fetch(transformed.url, {
method: init.method || "POST", method: init.method || "POST",
headers: transformed.headers, headers: transformed.headers,
@@ -113,7 +115,6 @@ async function attemptFetch(
signal: init.signal, signal: init.signal,
}) })
// Check for retryable errors
if (!response.ok && isRetryableError(response.status)) { if (!response.ok && isRetryableError(response.status)) {
debugLog(`Endpoint failed: ${endpoint} (status: ${response.status}), trying next`) debugLog(`Endpoint failed: ${endpoint} (status: ${response.status}), trying next`)
return null return null
@@ -121,7 +122,6 @@ async function attemptFetch(
return response return response
} catch (error) { } catch (error) {
// Network error - try next endpoint
debugLog( debugLog(
`Endpoint failed: ${endpoint} (${error instanceof Error ? error.message : "Unknown error"}), trying next` `Endpoint failed: ${endpoint} (${error instanceof Error ? error.message : "Unknown error"}), trying next`
) )
@@ -179,6 +179,8 @@ async function transformResponseWithThinking(
* @param getAuth - Async function to retrieve current auth state * @param getAuth - Async function to retrieve current auth state
* @param client - Auth client for saving updated tokens * @param client - Auth client for saving updated tokens
* @param providerId - Provider identifier (e.g., "google") * @param providerId - Provider identifier (e.g., "google")
* @param clientId - Optional custom client ID for token refresh (defaults to ANTIGRAVITY_CLIENT_ID)
* @param clientSecret - Optional custom client secret for token refresh (defaults to ANTIGRAVITY_CLIENT_SECRET)
* @returns Custom fetch function compatible with standard fetch signature * @returns Custom fetch function compatible with standard fetch signature
* *
* @example * @example
@@ -186,7 +188,9 @@ async function transformResponseWithThinking(
* const customFetch = createAntigravityFetch( * const customFetch = createAntigravityFetch(
* () => auth(), * () => auth(),
* client, * client,
* "google" * "google",
* "custom-client-id",
* "custom-client-secret"
* ) * )
* *
* // Use like standard fetch * // Use like standard fetch
@@ -199,7 +203,9 @@ async function transformResponseWithThinking(
export function createAntigravityFetch( export function createAntigravityFetch(
getAuth: () => Promise<Auth>, getAuth: () => Promise<Auth>,
client: AuthClient, client: AuthClient,
providerId: string providerId: string,
clientId?: string,
clientSecret?: string
): (url: string, init?: RequestInit) => Promise<Response> { ): (url: string, init?: RequestInit) => Promise<Response> {
// Cache for current token state // Cache for current token state
let cachedTokens: AntigravityTokens | null = null let cachedTokens: AntigravityTokens | null = null
@@ -237,7 +243,7 @@ export function createAntigravityFetch(
debugLog("Token expired, refreshing...") debugLog("Token expired, refreshing...")
try { try {
const newTokens = await refreshAccessToken(refreshParts.refreshToken) const newTokens = await refreshAccessToken(refreshParts.refreshToken, clientId, clientSecret)
// Update cached tokens // Update cached tokens
cachedTokens = { cachedTokens = {
@@ -297,7 +303,6 @@ export function createAntigravityFetch(
} }
} }
// Try each endpoint in fallback order
const maxEndpoints = Math.min(ANTIGRAVITY_ENDPOINT_FALLBACKS.length, 3) const maxEndpoints = Math.min(ANTIGRAVITY_ENDPOINT_FALLBACKS.length, 3)
for (let i = 0; i < maxEndpoints; i++) { for (let i = 0; i < maxEndpoints; i++) {
@@ -312,10 +317,13 @@ export function createAntigravityFetch(
modelName modelName
) )
if (response === "pass-through") {
debugLog("Non-string body detected, passing through to original fetch")
return fetch(url, init)
}
if (response) { if (response) {
debugLog(`Success with endpoint: ${endpoint}`) debugLog(`Success with endpoint: ${endpoint}`)
// Transform response (with thinking extraction if applicable)
return transformResponseWithThinking(response, modelName || "") return transformResponseWithThinking(response, modelName || "")
} }
} }

View File

@@ -121,10 +121,12 @@ export function decodeState(encoded: string): OAuthState {
* Build the OAuth authorization URL with PKCE. * Build the OAuth authorization URL with PKCE.
* *
* @param projectId - Optional GCP project ID to include in state * @param projectId - Optional GCP project ID to include in state
* @param clientId - Optional custom client ID (defaults to ANTIGRAVITY_CLIENT_ID)
* @returns Authorization result with URL and verifier * @returns Authorization result with URL and verifier
*/ */
export async function buildAuthURL( export async function buildAuthURL(
projectId?: string projectId?: string,
clientId: string = ANTIGRAVITY_CLIENT_ID
): Promise<AuthorizationResult> { ): Promise<AuthorizationResult> {
const pkce = await generatePKCEPair() const pkce = await generatePKCEPair()
@@ -134,7 +136,7 @@ export async function buildAuthURL(
} }
const url = new URL(GOOGLE_AUTH_URL) const url = new URL(GOOGLE_AUTH_URL)
url.searchParams.set("client_id", ANTIGRAVITY_CLIENT_ID) url.searchParams.set("client_id", clientId)
url.searchParams.set("redirect_uri", ANTIGRAVITY_REDIRECT_URI) url.searchParams.set("redirect_uri", ANTIGRAVITY_REDIRECT_URI)
url.searchParams.set("response_type", "code") url.searchParams.set("response_type", "code")
url.searchParams.set("scope", ANTIGRAVITY_SCOPES.join(" ")) url.searchParams.set("scope", ANTIGRAVITY_SCOPES.join(" "))
@@ -155,15 +157,19 @@ export async function buildAuthURL(
* *
* @param code - Authorization code from OAuth callback * @param code - Authorization code from OAuth callback
* @param verifier - PKCE verifier from initial auth request * @param verifier - PKCE verifier from initial auth request
* @param clientId - Optional custom client ID (defaults to ANTIGRAVITY_CLIENT_ID)
* @param clientSecret - Optional custom client secret (defaults to ANTIGRAVITY_CLIENT_SECRET)
* @returns Token exchange result with access and refresh tokens * @returns Token exchange result with access and refresh tokens
*/ */
export async function exchangeCode( export async function exchangeCode(
code: string, code: string,
verifier: string verifier: string,
clientId: string = ANTIGRAVITY_CLIENT_ID,
clientSecret: string = ANTIGRAVITY_CLIENT_SECRET
): Promise<AntigravityTokenExchangeResult> { ): Promise<AntigravityTokenExchangeResult> {
const params = new URLSearchParams({ const params = new URLSearchParams({
client_id: ANTIGRAVITY_CLIENT_ID, client_id: clientId,
client_secret: ANTIGRAVITY_CLIENT_SECRET, client_secret: clientSecret,
code, code,
grant_type: "authorization_code", grant_type: "authorization_code",
redirect_uri: ANTIGRAVITY_REDIRECT_URI, redirect_uri: ANTIGRAVITY_REDIRECT_URI,
@@ -317,18 +323,22 @@ export function startCallbackServer(
* *
* @param projectId - Optional GCP project ID * @param projectId - Optional GCP project ID
* @param openBrowser - Function to open URL in browser * @param openBrowser - Function to open URL in browser
* @param clientId - Optional custom client ID (defaults to ANTIGRAVITY_CLIENT_ID)
* @param clientSecret - Optional custom client secret (defaults to ANTIGRAVITY_CLIENT_SECRET)
* @returns Object with tokens and user info * @returns Object with tokens and user info
*/ */
export async function performOAuthFlow( export async function performOAuthFlow(
projectId?: string, projectId?: string,
openBrowser?: (url: string) => Promise<void> openBrowser?: (url: string) => Promise<void>,
clientId: string = ANTIGRAVITY_CLIENT_ID,
clientSecret: string = ANTIGRAVITY_CLIENT_SECRET
): Promise<{ ): Promise<{
tokens: AntigravityTokenExchangeResult tokens: AntigravityTokenExchangeResult
userInfo: AntigravityUserInfo userInfo: AntigravityUserInfo
verifier: string verifier: string
}> { }> {
// Build auth URL first to get the verifier // Build auth URL first to get the verifier
const auth = await buildAuthURL(projectId) const auth = await buildAuthURL(projectId, clientId)
// Start callback server // Start callback server
const callbackPromise = startCallbackServer() const callbackPromise = startCallbackServer()
@@ -356,7 +366,7 @@ export async function performOAuthFlow(
} }
// Exchange code for tokens // Exchange code for tokens
const tokens = await exchangeCode(callback.code, auth.verifier) const tokens = await exchangeCode(callback.code, auth.verifier, clientId, clientSecret)
// Fetch user info // Fetch user info
const userInfo = await fetchUserInfo(tokens.access_token) const userInfo = await fetchUserInfo(tokens.access_token)

View File

@@ -74,6 +74,12 @@ function isOAuthAuth(
export async function createGoogleAntigravityAuthPlugin({ export async function createGoogleAntigravityAuthPlugin({
client, client,
}: PluginInput): Promise<{ auth: AuthHook }> { }: PluginInput): Promise<{ auth: AuthHook }> {
// Cache for custom credentials from provider.options
// These are populated by loader() and used by authorize()
// Falls back to defaults if loader hasn't been called yet
let cachedClientId: string = ANTIGRAVITY_CLIENT_ID
let cachedClientSecret: string = ANTIGRAVITY_CLIENT_SECRET
const authHook: AuthHook = { const authHook: AuthHook = {
/** /**
* Provider identifier - must be "google" as Antigravity is * Provider identifier - must be "google" as Antigravity is
@@ -100,18 +106,16 @@ export async function createGoogleAntigravityAuthPlugin({
return {} return {}
} }
// Read credentials from provider.options (opencode.json) cachedClientId =
// Fall back to default credentials if not configured
const clientId =
(provider.options?.clientId as string) || ANTIGRAVITY_CLIENT_ID (provider.options?.clientId as string) || ANTIGRAVITY_CLIENT_ID
const clientSecret = cachedClientSecret =
(provider.options?.clientSecret as string) || ANTIGRAVITY_CLIENT_SECRET (provider.options?.clientSecret as string) || ANTIGRAVITY_CLIENT_SECRET
// Log if using custom credentials (for debugging) // Log if using custom credentials (for debugging)
if ( if (
process.env.ANTIGRAVITY_DEBUG === "1" && process.env.ANTIGRAVITY_DEBUG === "1" &&
(clientId !== ANTIGRAVITY_CLIENT_ID || (cachedClientId !== ANTIGRAVITY_CLIENT_ID ||
clientSecret !== ANTIGRAVITY_CLIENT_SECRET) cachedClientSecret !== ANTIGRAVITY_CLIENT_SECRET)
) { ) {
console.log( console.log(
"[antigravity-plugin] Using custom credentials from provider.options" "[antigravity-plugin] Using custom credentials from provider.options"
@@ -153,13 +157,12 @@ export async function createGoogleAntigravityAuthPlugin({
return {} return {}
} }
// Create the Antigravity fetch interceptor
// Note: The fetch interceptor uses constants for token refresh internally
// Custom credentials in provider.options are for future extensibility
const antigravityFetch = createAntigravityFetch( const antigravityFetch = createAntigravityFetch(
getAuth, getAuth,
authClient, authClient,
GOOGLE_PROVIDER_ID GOOGLE_PROVIDER_ID,
cachedClientId,
cachedClientSecret
) )
return { return {
@@ -185,8 +188,7 @@ export async function createGoogleAntigravityAuthPlugin({
* @returns Authorization result with URL and callback * @returns Authorization result with URL and callback
*/ */
authorize: async (): Promise<AuthOuathResult> => { authorize: async (): Promise<AuthOuathResult> => {
// Build OAuth URL with PKCE const { url, verifier } = await buildAuthURL(undefined, cachedClientId)
const { url, verifier } = await buildAuthURL()
// Start local callback server to receive OAuth callback // Start local callback server to receive OAuth callback
const callbackPromise = startCallbackServer() const callbackPromise = startCallbackServer()
@@ -208,28 +210,33 @@ export async function createGoogleAntigravityAuthPlugin({
// Check for errors // Check for errors
if (result.error) { if (result.error) {
if (process.env.ANTIGRAVITY_DEBUG === "1") {
console.error( console.error(
`[antigravity-plugin] OAuth error: ${result.error}` `[antigravity-plugin] OAuth error: ${result.error}`
) )
}
return { type: "failed" as const } return { type: "failed" as const }
} }
if (!result.code) { if (!result.code) {
if (process.env.ANTIGRAVITY_DEBUG === "1") {
console.error( console.error(
"[antigravity-plugin] No authorization code received" "[antigravity-plugin] No authorization code received"
) )
}
return { type: "failed" as const } return { type: "failed" as const }
} }
// Verify state and extract original verifier // Verify state and extract original verifier
const state = decodeState(result.state) const state = decodeState(result.state)
if (state.verifier !== verifier) { if (state.verifier !== verifier) {
if (process.env.ANTIGRAVITY_DEBUG === "1") {
console.error("[antigravity-plugin] PKCE verifier mismatch") console.error("[antigravity-plugin] PKCE verifier mismatch")
}
return { type: "failed" as const } return { type: "failed" as const }
} }
// Exchange authorization code for tokens const tokens = await exchangeCode(result.code, verifier, cachedClientId, cachedClientSecret)
const tokens = await exchangeCode(result.code, verifier)
// Fetch user info (optional, for logging) // Fetch user info (optional, for logging)
try { try {
@@ -263,11 +270,13 @@ export async function createGoogleAntigravityAuthPlugin({
expires: Date.now() + tokens.expires_in * 1000, expires: Date.now() + tokens.expires_in * 1000,
} }
} catch (error) { } catch (error) {
if (process.env.ANTIGRAVITY_DEBUG === "1") {
console.error( console.error(
`[antigravity-plugin] OAuth flow failed: ${ `[antigravity-plugin] OAuth flow failed: ${
error instanceof Error ? error.message : "Unknown error" error instanceof Error ? error.message : "Unknown error"
}` }`
) )
}
return { type: "failed" as const } return { type: "failed" as const }
} }
}, },

View File

@@ -4,7 +4,7 @@
* *
* Key responsibilities: * Key responsibilities:
* - Non-streaming response transformation * - Non-streaming response transformation
* - SSE streaming response transformation (preserving stream) * - SSE streaming response transformation (buffered - see transformStreamingResponse)
* - Error response handling with retry-after extraction * - Error response handling with retry-after extraction
* - Usage metadata extraction from x-antigravity-* headers * - Usage metadata extraction from x-antigravity-* headers
*/ */
@@ -340,19 +340,29 @@ export function transformStreamingPayload(payload: string): string {
} }
/** /**
* Transform a streaming SSE response * Transforms a streaming SSE response from Antigravity to OpenAI format.
* *
* For streaming responses: * **⚠️ CURRENT IMPLEMENTATION: BUFFERING**
* - Preserves the SSE format for downstream consumers * This implementation reads the entire stream into memory before transforming.
* While functional, it does not preserve true streaming characteristics:
* - Blocks until entire response is received
* - Consumes memory proportional to response size
* - Increases Time-To-First-Byte (TTFB)
*
* **TODO: Future Enhancement**
* Implement true streaming using ReadableStream transformation:
* - Parse SSE chunks incrementally
* - Transform and yield chunks as they arrive
* - Reduce memory footprint and TTFB
*
* For streaming responses (current buffered approach):
* - Unwraps the `response` field from each SSE event * - Unwraps the `response` field from each SSE event
* - Returns transformed SSE text as new Response
* - Extracts usage metadata from headers * - Extracts usage metadata from headers
* *
* Note: This reads the entire stream and returns a new Response.
* The stream is preserved as SSE text, not blocked.
*
* Note: Does NOT handle thinking block extraction (Task 10) * Note: Does NOT handle thinking block extraction (Task 10)
* *
* @param response - Fetch Response object with SSE body * @param response - The SSE response from Antigravity API
* @returns TransformResult with transformed response and metadata * @returns TransformResult with transformed response and metadata
*/ */
export async function transformStreamingResponse(response: Response): Promise<TransformResult> { export async function transformStreamingResponse(response: Response): Promise<TransformResult> {
@@ -425,6 +435,7 @@ export async function transformStreamingResponse(response: Response): Promise<Tr
} }
// Handle SSE stream // Handle SSE stream
// NOTE: Current implementation buffers entire stream - see JSDoc for details
try { try {
const text = await response.text() const text = await response.text()
const transformed = transformStreamingPayload(text) const transformed = transformStreamingPayload(text)

View File

@@ -36,16 +36,20 @@ export function isTokenExpired(tokens: AntigravityTokens): boolean {
* Exchanges the refresh token for a new access token via Google's OAuth endpoint. * Exchanges the refresh token for a new access token via Google's OAuth endpoint.
* *
* @param refreshToken - The refresh token to use * @param refreshToken - The refresh token to use
* @param clientId - Optional custom client ID (defaults to ANTIGRAVITY_CLIENT_ID)
* @param clientSecret - Optional custom client secret (defaults to ANTIGRAVITY_CLIENT_SECRET)
* @returns Token exchange result with new access token, or throws on error * @returns Token exchange result with new access token, or throws on error
*/ */
export async function refreshAccessToken( export async function refreshAccessToken(
refreshToken: string refreshToken: string,
clientId: string = ANTIGRAVITY_CLIENT_ID,
clientSecret: string = ANTIGRAVITY_CLIENT_SECRET
): Promise<AntigravityTokenExchangeResult> { ): Promise<AntigravityTokenExchangeResult> {
const params = new URLSearchParams({ const params = new URLSearchParams({
grant_type: "refresh_token", grant_type: "refresh_token",
refresh_token: refreshToken, refresh_token: refreshToken,
client_id: ANTIGRAVITY_CLIENT_ID, client_id: clientId,
client_secret: ANTIGRAVITY_CLIENT_SECRET, client_secret: clientSecret,
}) })
const response = await fetch(GOOGLE_TOKEN_URL, { const response = await fetch(GOOGLE_TOKEN_URL, {

View File

@@ -117,13 +117,15 @@ export function normalizeToolsForGemini(
functionDeclarations.push(declaration) functionDeclarations.push(declaration)
} else { } else {
// Log warning for unsupported tool types // Log warning for unsupported tool types (debug only)
if (process.env.ANTIGRAVITY_DEBUG === "1") {
console.warn( console.warn(
`[antigravity-tools] Unsupported tool type: "${tool.type}". ` + `[antigravity-tools] Unsupported tool type: "${tool.type}". ` +
`Only "function" type tools are supported for Gemini. Tool will be skipped.` `Only "function" type tools are supported for Gemini. Tool will be skipped.`
) )
} }
} }
}
// Return undefined if no valid function declarations // Return undefined if no valid function declarations
if (functionDeclarations.length === 0) { if (functionDeclarations.length === 0) {