Files
opencode-antigravity-auth/src/plugin.ts

959 lines
34 KiB
TypeScript

import { exec } from "node:child_process";
import { ANTIGRAVITY_ENDPOINT_FALLBACKS, ANTIGRAVITY_PROVIDER_ID } from "./constants";
import { authorizeAntigravity, exchangeAntigravity } from "./antigravity/oauth";
import type { AntigravityTokenExchangeResult } from "./antigravity/oauth";
import { accessTokenExpired, isOAuthAuth, parseRefreshParts } from "./plugin/auth";
import { promptAddAnotherAccount, promptLoginMode, promptProjectId } from "./plugin/cli";
import { ensureProjectContext } from "./plugin/project";
import { startAntigravityDebugRequest } from "./plugin/debug";
import {
isGenerativeLanguageRequest,
prepareAntigravityRequest,
transformAntigravityResponse,
} from "./plugin/request";
import { AntigravityTokenRefreshError, refreshAccessToken } from "./plugin/token";
import { startOAuthListener, type OAuthListener } from "./plugin/server";
import { clearAccounts, loadAccounts, saveAccounts } from "./plugin/storage";
import { AccountManager } from "./plugin/accounts";
import type {
GetAuth,
LoaderResult,
PluginContext,
PluginResult,
ProjectContextResult,
Provider,
} from "./plugin/types";
const MAX_OAUTH_ACCOUNTS = 10;
async function openBrowser(url: string): Promise<void> {
try {
if (process.platform === "darwin") {
exec(`open "${url}"`);
return;
}
if (process.platform === "win32") {
exec(`start "${url}"`);
return;
}
exec(`xdg-open "${url}"`);
} catch {
// ignore
}
}
async function promptOAuthCallbackValue(message: string): Promise<string> {
const { createInterface } = await import("node:readline/promises");
const { stdin, stdout } = await import("node:process");
const rl = createInterface({ input: stdin, output: stdout });
try {
return (await rl.question(message)).trim();
} finally {
rl.close();
}
}
type OAuthCallbackParams = { code: string; state: string };
function getStateFromAuthorizationUrl(authorizationUrl: string): string {
try {
return new URL(authorizationUrl).searchParams.get("state") ?? "";
} catch {
return "";
}
}
function extractOAuthCallbackParams(url: URL): OAuthCallbackParams | null {
const code = url.searchParams.get("code");
const state = url.searchParams.get("state");
if (!code || !state) {
return null;
}
return { code, state };
}
function parseOAuthCallbackInput(
value: string,
fallbackState: string,
): OAuthCallbackParams | { error: string } {
const trimmed = value.trim();
if (!trimmed) {
return { error: "Missing authorization code" };
}
try {
const url = new URL(trimmed);
const code = url.searchParams.get("code");
const state = url.searchParams.get("state") ?? fallbackState;
if (!code) {
return { error: "Missing code in callback URL" };
}
if (!state) {
return { error: "Missing state in callback URL" };
}
return { code, state };
} catch {
if (!fallbackState) {
return { error: "Missing state. Paste the full redirect URL instead of only the code." };
}
return { code: trimmed, state: fallbackState };
}
}
function clampInt(value: number, min: number, max: number): number {
if (!Number.isFinite(value)) {
return min;
}
return Math.min(max, Math.max(min, Math.floor(value)));
}
async function persistAccountPool(
results: Array<Extract<AntigravityTokenExchangeResult, { type: "success" }>>,
replaceAll: boolean = false,
): Promise<void> {
if (results.length === 0) {
return;
}
const now = Date.now();
// If replaceAll is true (fresh login), start with empty accounts
// Otherwise, load existing accounts and merge
const stored = replaceAll ? null : await loadAccounts();
const accounts = stored?.accounts ? [...stored.accounts] : [];
const indexByRefreshToken = new Map<string, number>();
for (let i = 0; i < accounts.length; i++) {
const token = accounts[i]?.refreshToken;
if (token) {
indexByRefreshToken.set(token, i);
}
}
for (const result of results) {
const parts = parseRefreshParts(result.refresh);
if (!parts.refreshToken) {
continue;
}
const existingIndex = indexByRefreshToken.get(parts.refreshToken);
if (existingIndex === undefined) {
indexByRefreshToken.set(parts.refreshToken, accounts.length);
accounts.push({
email: result.email,
refreshToken: parts.refreshToken,
projectId: parts.projectId,
managedProjectId: parts.managedProjectId,
addedAt: now,
lastUsed: now,
isRateLimited: false,
rateLimitResetTime: 0,
});
continue;
}
const existing = accounts[existingIndex];
if (!existing) {
continue;
}
accounts[existingIndex] = {
...existing,
email: result.email ?? existing.email,
projectId: parts.projectId ?? existing.projectId,
managedProjectId: parts.managedProjectId ?? existing.managedProjectId,
lastUsed: now,
};
}
if (accounts.length === 0) {
return;
}
// For fresh logins, always start at index 0
const activeIndex = replaceAll
? 0
: (typeof stored?.activeIndex === "number" && Number.isFinite(stored.activeIndex) ? stored.activeIndex : 0);
await saveAccounts({
version: 1,
accounts,
activeIndex: clampInt(activeIndex, 0, accounts.length - 1),
});
}
function retryAfterMsFromResponse(response: Response): number {
const retryAfterMsHeader = response.headers.get("retry-after-ms");
if (retryAfterMsHeader) {
const parsed = Number.parseInt(retryAfterMsHeader, 10);
if (!Number.isNaN(parsed) && parsed > 0) {
return parsed;
}
}
const retryAfterHeader = response.headers.get("retry-after");
if (retryAfterHeader) {
const parsed = Number.parseInt(retryAfterHeader, 10);
if (!Number.isNaN(parsed) && parsed > 0) {
return parsed * 1000;
}
}
return 60_000;
}
/**
* Sleep for a given number of milliseconds, respecting an abort signal.
*/
function sleep(ms: number, signal?: AbortSignal | null): Promise<void> {
return new Promise((resolve, reject) => {
if (signal?.aborted) {
reject(signal.reason instanceof Error ? signal.reason : new Error("Aborted"));
return;
}
const timeout = setTimeout(() => {
cleanup();
resolve();
}, ms);
const onAbort = () => {
cleanup();
reject(signal?.reason instanceof Error ? signal.reason : new Error("Aborted"));
};
const cleanup = () => {
clearTimeout(timeout);
signal?.removeEventListener("abort", onAbort);
};
signal?.addEventListener("abort", onAbort, { once: true });
});
}
/**
* Creates an Antigravity OAuth plugin for a specific provider ID.
*/
export const createAntigravityPlugin = (providerId: string) => async (
{ client }: PluginContext,
): Promise<PluginResult> => ({
auth: {
provider: providerId,
loader: async (getAuth: GetAuth, provider: Provider): Promise<LoaderResult | Record<string, unknown>> => {
const auth = await getAuth();
// If OpenCode has no valid OAuth auth, clear any stale account storage
if (!isOAuthAuth(auth)) {
try {
await clearAccounts();
} catch {
// ignore
}
return {};
}
// Validate that stored accounts are in sync with OpenCode's auth
// If OpenCode's refresh token doesn't match any stored account, clear stale storage
const authParts = parseRefreshParts(auth.refresh);
const storedAccounts = await loadAccounts();
if (storedAccounts && storedAccounts.accounts.length > 0 && authParts.refreshToken) {
const hasMatchingAccount = storedAccounts.accounts.some(
(acc) => acc.refreshToken === authParts.refreshToken
);
if (!hasMatchingAccount) {
// OpenCode's auth doesn't match any stored account - storage is stale
// Clear it and let the user re-authenticate
console.warn(
"[opencode-antigravity-auth] Stored accounts don't match OpenCode's auth. Clearing stale storage."
);
try {
await clearAccounts();
} catch {
// ignore
}
}
}
const accountManager = await AccountManager.loadFromDisk(auth);
if (accountManager.getAccountCount() > 0) {
try {
await accountManager.saveToDisk();
} catch (error) {
console.error("[opencode-antigravity-auth] Failed to persist initial account pool:", error);
}
}
if (provider.models) {
for (const model of Object.values(provider.models)) {
if (model) {
model.cost = { input: 0, output: 0 };
}
}
}
return {
apiKey: "",
async fetch(input, init) {
// If the request is for the *other* provider, we might still want to intercept if URL matches
// But strict compliance means we only handle requests if the auth provider matches.
// Since loader is instantiated per provider, we are good.
if (!isGenerativeLanguageRequest(input)) {
return fetch(input, init);
}
const latestAuth = await getAuth();
if (!isOAuthAuth(latestAuth)) {
return fetch(input, init);
}
if (accountManager.getAccountCount() === 0) {
throw new Error("No Antigravity accounts configured. Run `opencode auth login`.");
}
type FailureContext = {
response: Response;
streaming: boolean;
debugContext: ReturnType<typeof startAntigravityDebugRequest>;
requestedModel?: string;
projectId?: string;
endpoint?: string;
effectiveModel?: string;
sessionId?: string;
toolDebugMissing?: number;
toolDebugSummary?: string;
toolDebugPayload?: string;
};
let lastFailure: FailureContext | null = null;
let lastError: Error | null = null;
const abortSignal = init?.signal ?? undefined;
// Track which account was used in this request for detecting switches
// This is scoped to the fetch call so it resets per-request
let previousAccountIndex: number | null = null;
// Helper to check if request was aborted
const checkAborted = () => {
if (abortSignal?.aborted) {
throw abortSignal.reason instanceof Error ? abortSignal.reason : new Error("Aborted");
}
};
// Helper to show toast without blocking on abort
const showToast = async (message: string, variant: "info" | "warning" | "success" | "error") => {
if (abortSignal?.aborted) return;
try {
await client.tui.showToast({
body: { message, variant },
});
} catch {
// TUI may not be available
}
};
// Use while(true) loop to handle rate limits with backoff
// This ensures we wait and retry when all accounts are rate-limited
while (true) {
// Check for abort at the start of each iteration
checkAborted();
const accountCount = accountManager.getAccountCount();
if (accountCount === 0) {
throw new Error("No Antigravity accounts available. Run `opencode auth login`.");
}
const account = accountManager.pickNext();
if (!account) {
// All accounts are rate-limited - wait and retry
const waitMs = accountManager.getMinWaitTimeMs() || 60_000;
const waitSec = Math.max(1, Math.ceil(waitMs / 1000));
await showToast(`All ${accountCount} account(s) rate-limited. Waiting ${waitSec}s...`, "warning");
// Wait for the cooldown to expire
await sleep(waitMs, abortSignal);
continue;
}
// Show toast when switching to a different account
const isAccountSwitch = previousAccountIndex !== null && previousAccountIndex !== account.index;
if ((isAccountSwitch || previousAccountIndex === null) && accountCount > 1) {
const accountLabel = account.email || `Account ${account.index + 1}`;
await showToast(
`Using ${accountLabel}${accountCount > 1 ? ` (${account.index + 1}/${accountCount})` : ""}`,
"info"
);
}
previousAccountIndex = account.index;
try {
await accountManager.saveToDisk();
} catch (error) {
console.error("[opencode-antigravity-auth] Failed to persist rotation state:", error);
}
let authRecord = accountManager.toAuthDetails(account);
if (accessTokenExpired(authRecord)) {
try {
const refreshed = await refreshAccessToken(authRecord, client, providerId);
if (!refreshed) {
lastError = new Error("Antigravity token refresh failed");
continue;
}
accountManager.updateFromAuth(account, refreshed);
authRecord = refreshed;
try {
await accountManager.saveToDisk();
} catch (error) {
console.error("[opencode-antigravity-auth] Failed to persist refreshed auth:", error);
}
} catch (error) {
if (error instanceof AntigravityTokenRefreshError && error.code === "invalid_grant") {
const removed = accountManager.removeAccount(account);
if (removed) {
console.warn(
"[opencode-antigravity-auth] Removed revoked account from pool. Reauthenticate it via `opencode auth login` to add it back.",
);
try {
await accountManager.saveToDisk();
} catch (persistError) {
console.error(
"[opencode-antigravity-auth] Failed to persist revoked account removal:",
persistError,
);
}
}
if (accountManager.getAccountCount() === 0) {
try {
await client.auth.set({
path: { id: providerId },
body: { type: "oauth", refresh: "", access: "", expires: 0 },
});
} catch (storeError) {
console.error("Failed to clear stored Antigravity OAuth credentials:", storeError);
}
throw new Error(
"All Antigravity accounts have invalid refresh tokens. Run `opencode auth login` and reauthenticate.",
);
}
lastError = error;
continue;
}
lastError = error instanceof Error ? error : new Error(String(error));
continue;
}
}
const accessToken = authRecord.access;
if (!accessToken) {
lastError = new Error("Missing access token");
continue;
}
let projectContext: ProjectContextResult;
try {
projectContext = await ensureProjectContext(authRecord);
} catch (error) {
lastError = error instanceof Error ? error : new Error(String(error));
continue;
}
if (projectContext.auth !== authRecord) {
accountManager.updateFromAuth(account, projectContext.auth);
authRecord = projectContext.auth;
try {
await accountManager.saveToDisk();
} catch (error) {
console.error("[opencode-antigravity-auth] Failed to persist project context:", error);
}
}
// Try endpoint fallbacks
let shouldSwitchAccount = false;
for (let i = 0; i < ANTIGRAVITY_ENDPOINT_FALLBACKS.length; i++) {
const currentEndpoint = ANTIGRAVITY_ENDPOINT_FALLBACKS[i];
try {
const prepared = prepareAntigravityRequest(
input,
init,
accessToken,
projectContext.effectiveProjectId,
currentEndpoint,
);
const originalUrl = toUrlString(input);
const resolvedUrl = toUrlString(prepared.request);
const debugContext = startAntigravityDebugRequest({
originalUrl,
resolvedUrl,
method: prepared.init.method,
headers: prepared.init.headers,
body: prepared.init.body,
streaming: prepared.streaming,
projectId: projectContext.effectiveProjectId,
});
const response = await fetch(prepared.request, prepared.init);
// Handle 429 rate limit
if (response.status === 429) {
const retryAfterMs = retryAfterMsFromResponse(response);
accountManager.markRateLimited(account, retryAfterMs);
try {
await accountManager.saveToDisk();
} catch (error) {
console.error("[opencode-antigravity-auth] Failed to persist rate-limit state:", error);
}
const accountLabel = account.email || `Account ${account.index + 1}`;
if (accountManager.getAccountCount() > 1) {
// Multiple accounts - switch to next
await showToast(`Rate limited on ${accountLabel}. Switching...`, "warning");
lastFailure = {
response,
streaming: prepared.streaming,
debugContext,
requestedModel: prepared.requestedModel,
projectId: prepared.projectId,
endpoint: prepared.endpoint,
effectiveModel: prepared.effectiveModel,
sessionId: prepared.sessionId,
toolDebugMissing: prepared.toolDebugMissing,
toolDebugSummary: prepared.toolDebugSummary,
toolDebugPayload: prepared.toolDebugPayload,
};
shouldSwitchAccount = true;
break;
} else {
// Single account - wait and retry
const waitSec = Math.max(1, Math.ceil(retryAfterMs / 1000));
await showToast(`Rate limited. Waiting ${waitSec}s...`, "warning");
lastFailure = {
response,
streaming: prepared.streaming,
debugContext,
requestedModel: prepared.requestedModel,
projectId: prepared.projectId,
endpoint: prepared.endpoint,
effectiveModel: prepared.effectiveModel,
sessionId: prepared.sessionId,
toolDebugMissing: prepared.toolDebugMissing,
toolDebugSummary: prepared.toolDebugSummary,
toolDebugPayload: prepared.toolDebugPayload,
};
// Wait and let the outer loop retry
await sleep(retryAfterMs, abortSignal);
shouldSwitchAccount = true;
break;
}
}
const shouldRetryEndpoint = (
response.status === 403 ||
response.status === 404 ||
response.status >= 500
);
if (shouldRetryEndpoint && i < ANTIGRAVITY_ENDPOINT_FALLBACKS.length - 1) {
lastFailure = {
response,
streaming: prepared.streaming,
debugContext,
requestedModel: prepared.requestedModel,
projectId: prepared.projectId,
endpoint: prepared.endpoint,
effectiveModel: prepared.effectiveModel,
sessionId: prepared.sessionId,
toolDebugMissing: prepared.toolDebugMissing,
toolDebugSummary: prepared.toolDebugSummary,
toolDebugPayload: prepared.toolDebugPayload,
};
continue;
}
// Success or non-retryable error - return the response
return transformAntigravityResponse(
response,
prepared.streaming,
debugContext,
prepared.requestedModel,
prepared.projectId,
prepared.endpoint,
prepared.effectiveModel,
prepared.sessionId,
prepared.toolDebugMissing,
prepared.toolDebugSummary,
prepared.toolDebugPayload,
);
} catch (error) {
if (i < ANTIGRAVITY_ENDPOINT_FALLBACKS.length - 1) {
lastError = error instanceof Error ? error : new Error(String(error));
continue;
}
// All endpoints failed for this account - try next account
lastError = error instanceof Error ? error : new Error(String(error));
shouldSwitchAccount = true;
break;
}
}
if (shouldSwitchAccount) {
continue;
}
// If we get here without returning, something went wrong
if (lastFailure) {
return transformAntigravityResponse(
lastFailure.response,
lastFailure.streaming,
lastFailure.debugContext,
lastFailure.requestedModel,
lastFailure.projectId,
lastFailure.endpoint,
lastFailure.effectiveModel,
lastFailure.sessionId,
lastFailure.toolDebugMissing,
lastFailure.toolDebugSummary,
lastFailure.toolDebugPayload,
);
}
throw lastError || new Error("All Antigravity accounts failed");
}
},
};
},
methods: [
{
label: "OAuth with Google (Antigravity)",
type: "oauth",
authorize: async (inputs?: Record<string, string>) => {
const isHeadless = !!(
process.env.SSH_CONNECTION ||
process.env.SSH_CLIENT ||
process.env.SSH_TTY ||
process.env.OPENCODE_HEADLESS
);
// CLI flow (`opencode auth login`) passes an inputs object.
if (inputs) {
const accounts: Array<Extract<AntigravityTokenExchangeResult, { type: "success" }>> = [];
// Check for existing accounts and prompt user for login mode
let startFresh = true;
const existingStorage = await loadAccounts();
if (existingStorage && existingStorage.accounts.length > 0) {
const existingAccounts = existingStorage.accounts.map((acc, idx) => ({
email: acc.email,
index: idx,
}));
const loginMode = await promptLoginMode(existingAccounts);
startFresh = loginMode === "fresh";
if (startFresh) {
console.log("\nStarting fresh - existing accounts will be replaced.\n");
} else {
console.log("\nAdding to existing accounts.\n");
}
}
while (accounts.length < MAX_OAUTH_ACCOUNTS) {
console.log(`\n=== Antigravity OAuth (Account ${accounts.length + 1}) ===`);
const projectId = await promptProjectId();
const result = await (async (): Promise<AntigravityTokenExchangeResult> => {
let listener: OAuthListener | null = null;
if (!isHeadless) {
try {
listener = await startOAuthListener();
} catch {
listener = null;
}
}
const authorization = await authorizeAntigravity(projectId);
const fallbackState = getStateFromAuthorizationUrl(authorization.url);
console.log("\nOAuth URL:\n" + authorization.url + "\n");
if (!isHeadless) {
await openBrowser(authorization.url);
}
if (listener) {
try {
const callbackUrl = await listener.waitForCallback();
const params = extractOAuthCallbackParams(callbackUrl);
if (!params) {
return { type: "failed", error: "Missing code or state in callback URL" };
}
return exchangeAntigravity(params.code, params.state);
} catch (error) {
return {
type: "failed",
error: error instanceof Error ? error.message : "Unknown error",
};
} finally {
try {
await listener.close();
} catch {
// ignore
}
}
}
console.log("1. Open the URL below in your browser and complete Google sign-in.");
console.log(
"2. After approving, copy the full redirected localhost URL from the address bar.",
);
console.log("3. Paste it back here.");
const callbackInput = await promptOAuthCallbackValue(
"Paste the redirect URL (or just the code) here: ",
);
const params = parseOAuthCallbackInput(callbackInput, fallbackState);
if ("error" in params) {
return { type: "failed", error: params.error };
}
return exchangeAntigravity(params.code, params.state);
})();
if (result.type === "failed") {
if (accounts.length === 0) {
return {
url: "",
instructions: `Authentication failed: ${result.error}`,
method: "auto",
callback: async () => result,
};
}
console.warn(
`[opencode-antigravity-auth] Skipping failed account ${accounts.length + 1}: ${result.error}`,
);
break;
}
accounts.push(result);
// Show toast for successful account authentication
try {
await client.tui.showToast({
body: {
message: `Account ${accounts.length} authenticated${result.email ? ` (${result.email})` : ""}`,
variant: "success",
},
});
} catch {
// TUI may not be available in CLI mode
}
try {
// Use startFresh only on first account, subsequent accounts always append
const isFirstAccount = accounts.length === 1;
await persistAccountPool([result], isFirstAccount && startFresh);
} catch {
// ignore
}
if (accounts.length >= MAX_OAUTH_ACCOUNTS) {
break;
}
const addAnother = await promptAddAnotherAccount(accounts.length);
if (!addAnother) {
break;
}
}
const primary = accounts[0];
if (!primary) {
return {
url: "",
instructions: "Authentication cancelled",
method: "auto",
callback: async () => ({ type: "failed", error: "Authentication cancelled" }),
};
}
return {
url: "",
instructions: `Multi-account setup complete (${accounts.length} account(s)).`,
method: "auto",
callback: async (): Promise<AntigravityTokenExchangeResult> => primary,
};
}
// TUI flow (`/connect`) does not support per-account prompts.
// Default to adding new accounts (non-destructive).
// Users can run `opencode auth logout` first if they want a fresh start.
const projectId = "";
// Check existing accounts count for toast message
const existingStorage = await loadAccounts();
const existingCount = existingStorage?.accounts.length ?? 0;
let listener: OAuthListener | null = null;
if (!isHeadless) {
try {
listener = await startOAuthListener();
} catch {
listener = null;
}
}
const authorization = await authorizeAntigravity(projectId);
const fallbackState = getStateFromAuthorizationUrl(authorization.url);
if (!isHeadless) {
await openBrowser(authorization.url);
}
if (listener) {
return {
url: authorization.url,
instructions:
"Complete sign-in in your browser. We'll automatically detect the redirect back to localhost.",
method: "auto",
callback: async (): Promise<AntigravityTokenExchangeResult> => {
try {
const callbackUrl = await listener.waitForCallback();
const params = extractOAuthCallbackParams(callbackUrl);
if (!params) {
return { type: "failed", error: "Missing code or state in callback URL" };
}
const result = await exchangeAntigravity(params.code, params.state);
if (result.type === "success") {
try {
// TUI flow adds to existing accounts (non-destructive)
await persistAccountPool([result], false);
} catch {
// ignore
}
// Show appropriate toast message
const newTotal = existingCount + 1;
const toastMessage = existingCount > 0
? `Added account${result.email ? ` (${result.email})` : ""} - ${newTotal} total`
: `Authenticated${result.email ? ` (${result.email})` : ""}`;
try {
await client.tui.showToast({
body: {
message: toastMessage,
variant: "success",
},
});
} catch {
// TUI may not be available
}
}
return result;
} catch (error) {
return {
type: "failed",
error: error instanceof Error ? error.message : "Unknown error",
};
} finally {
try {
await listener.close();
} catch {
// ignore
}
}
},
};
}
return {
url: authorization.url,
instructions:
"Visit the URL above, complete OAuth, then paste either the full redirect URL or the authorization code.",
method: "code",
callback: async (codeInput: string): Promise<AntigravityTokenExchangeResult> => {
const params = parseOAuthCallbackInput(codeInput, fallbackState);
if ("error" in params) {
return { type: "failed", error: params.error };
}
const result = await exchangeAntigravity(params.code, params.state);
if (result.type === "success") {
try {
// TUI flow adds to existing accounts (non-destructive)
await persistAccountPool([result], false);
} catch {
// ignore
}
// Show appropriate toast message
const newTotal = existingCount + 1;
const toastMessage = existingCount > 0
? `Added account${result.email ? ` (${result.email})` : ""} - ${newTotal} total`
: `Authenticated${result.email ? ` (${result.email})` : ""}`;
try {
await client.tui.showToast({
body: {
message: toastMessage,
variant: "success",
},
});
} catch {
// TUI may not be available
}
}
return result;
},
};
},
},
{
label: "Manually enter API Key",
type: "api",
},
],
},
});
export const AntigravityCLIOAuthPlugin = createAntigravityPlugin(ANTIGRAVITY_PROVIDER_ID);
export const GoogleOAuthPlugin = AntigravityCLIOAuthPlugin;
function toUrlString(value: RequestInfo): string {
if (typeof value === "string") {
return value;
}
const candidate = (value as Request).url;
if (candidate) {
return candidate;
}
return value.toString();
}