Skip to main content
Source Link

Need help: "2 Unkown: Getting metadata from plugin failed with error: Could not refresh access token: Request failed with status code 500"

I have begun creating an application (Next.JS running on Firebase App Hosting) in Firebase Studio using Gemini and everything was working fine until now that I got a notification about "Client Access is expiring". I tweaked the related areas and now I am getting this error which I am unable to fix:

2 Unknown: Getting metadata from plugin failed with error: Could not refresh access token: Request failed with status code 500

Has anyone experienced this bug or can help me figure out what I'd need to do? ((Complete newb to coding))

1. Firebase Admin SDK Initialization

// server-only
import { getApps, initializeApp, cert, applicationDefault } from 'firebase-admin/app';
import { getFirestore } from 'firebase-admin/firestore';

if (!getApps().length) {
  // Use GOOGLE_APPLICATION_CREDENTIALS environment variable if available,
  // otherwise fall back to applicationDefault() which works in most GCP environments.
  // This provides a more robust initialization.
  initializeApp({
    credential: process.env.GOOGLE_APPLICATION_CREDENTIALS
      ? cert(JSON.parse(process.env.GOOGLE_APPLICATION_CREDENTIALS))
      : applicationDefault(),
  });
}

export const adminDb = getFirestore();

2. Server-Side Flow Logic

'use server';
/**
 * @fileOverview A flow to run a prompt, get a response from an LLM, and extract citations and mentions.
 *
 * - runPrompt - A function that orchestrates the prompt execution and analysis.
 * - RunPromptInput - The input type for the runPrompt function.
 * - RunPromptOutput - The return type for the runPrompt function.
 */

import {ai} from '@/ai/genkit';
import {
  ExtractCitationsAndMentionsOutputSchema,
  RunPromptInputSchema,
} from '@/ai/schemas';
import {z} from 'genkit';
import {
  extractCitationsAndMentions,
  ExtractCitationsAndMentionsOutput,
} from './extract-citations-and-mentions';
import { adminDb } from '@/lib/firebase-admin';

export type RunPromptInput = z.infer<typeof RunPromptInputSchema>;
export type RunPromptOutput = ExtractCitationsAndMentionsOutput;

export async function runPrompt(input: RunPromptInput): Promise<RunPromptOutput> {
  return runPromptFlow(input);
}

const runPromptFlow = ai.defineFlow(
  {
    name: 'runPromptFlow',
    inputSchema: RunPromptInputSchema,
    outputSchema: ExtractCitationsAndMentionsOutputSchema,
  },
  async input => {
    const db = adminDb;

    const projectDocRef = db.collection('projects').doc(input.projectId);
    const projectDocSnap = await projectDocRef.get();
    if (!projectDocSnap.exists) {
      throw new Error(`Project with ID ${input.projectId} not found.`);
    }
    const projectData = projectDocSnap.data()!;
    const domain = projectData.domain || '';
    const brandName = projectData.brandName || '';
    
    let llmResponseText: string;
    let groundingMeta: any = null;

    try {
        const resp = await ai.generate({
          prompt: input.prompt,
          config: {
            tools: [{ googleSearch: {} }],
          },
        });
        
        if (!resp.text) {
          throw new Error('LLM failed to generate a response.');
        }
        llmResponseText = resp.text;
        groundingMeta = resp.custom?.candidates?.[0]?.groundingMetadata ?? null;

    } catch (e: any) {
        console.error('ai.generate failed', { message: e?.message, code: e?.code, stack: e?.stack });
        if (e?.message?.includes('API key')) {
            throw new Error('Model API key is not configured or invalid. Check your environment variables.');
        }
        // Re-throw other errors including our custom "failed to generate" error.
        throw e;
    }

    const analysis = await extractCitationsAndMentions({
      text: llmResponseText,
      domain: domain,
      brandName: brandName,
      brandAliases: [], // This can be extended to use project settings later
    });

    // --- Begin NEW replacement block ---

    // ===== CONFIG TOGGLES =====
    const RESOLVE_REDIRECTS = true;   // Optional: set to false to disable redirect resolution
    const MAX_REDIRECTS_TO_RESOLVE = 5; // Limit for performance

    // ===== HELPERS =====
    const toUrl = (maybeUrl: string): string | null => {
      if (!maybeUrl) return null;
      try {
        const hasScheme = /^https?:\/\//i.test(maybeUrl);
        return new URL(hasScheme ? maybeUrl : `https://${maybeUrl}`).toString();
      } catch {
        return null;
      }
    };

    const normalizeHost = (u: string): string => {
      try {
        const h = new URL(u).hostname.toLowerCase();
        return h.startsWith('www.') ? h.slice(4) : h;
      } catch {
        return '';
      }
    };

    // Project host (accepts bare domain or full URL)
    const projectHost = domain ? normalizeHost(toUrl(domain) || '') : '';

    const isProjectDomain = (host: string) =>
      !projectHost || host === projectHost || host.endsWith(`.${projectHost}`);

    const isVertexRedirect = (u: string | null) =>
      !!u && normalizeHost(u) === 'vertexaisearch.cloud.google.com';

    // Follow the Google redirect just enough to extract the publisher URL from Location.
    // Keep a short timeout and limit how many you resolve per run.
    async function resolveRedirect(redirectUrl: string, ms = 1500): Promise<string | null> {
      const controller = new AbortController();
      const timer = setTimeout(() => controller.abort(), ms);
      try {
        const res = await fetch(redirectUrl, { method: 'HEAD', redirect: 'manual', signal: controller.signal });
        const loc = res.headers.get('location');
        return loc ? toUrl(loc) : null;
      } catch {
        return null;
      } finally {
        clearTimeout(timer);
      }
    }

    // ===== GROUNDING SOURCES (redirect + domain + title) =====
    type GroundedSource = {
      redirectUri: string | null;   // Google redirect (or publisher if not redirected)
      domain: string | null;        // Publisher domain from metadata (string like "example.com")
      title: string | null;         // Publisher/site title
      resolvedUri?: string | null;  // Optional publisher URL after resolving redirect
    };

    const grounded: GroundedSource[] = (groundingMeta?.groundingChunks ?? []).map((c: any) => {
      const redirectUri = toUrl(c?.web?.uri || '');
      // Prefer the 'domain' field from metadata if present; it may be a bare host.
      const domainFromMeta = (c?.web?.domain ?? null) as string | null;
      const title = (c?.web?.title ?? null) as string | null;
      return { redirectUri, domain: domainFromMeta, title };
    });

    // ===== OPTIONAL: RESOLVE A FEW REDIRECTS TO GET PUBLISHER URL =====
    if (RESOLVE_REDIRECTS && grounded.length > 0) {
      const toResolve = grounded
        .filter(s => isVertexRedirect(s.redirectUri))
        .slice(0, MAX_REDIRECTS_TO_RESOLVE);

      const resolutions = await Promise.allSettled(
        toResolve.map(s => resolveRedirect(s.redirectUri!))
      );

      resolutions.forEach((res, idx) => {
        const s = toResolve[idx];
        if (res.status === 'fulfilled' && res.value) {
          s.resolvedUri = res.value;
          // If we got a final URL, refresh the domain to the publisher’s host.
          s.domain = normalizeHost(res.value);
        }
      });
    }
    
    // --- BEGIN patched sources & citation logic ---

    // 1) Persist ALL grounded sources (no domain filtering) for debugging/analytics
    const allSources = grounded.map(s => ({
      redirectUri: s.redirectUri,            // Google redirect (or direct)
      resolvedUri: s.resolvedUri ?? null,    // publisher URL if we resolved it
      domain: s.domain ?? null,              // publisher domain (metadata or from resolved URL)
      title: s.title ?? null,
    }));

    // 2) Build the project-filtered subset used by the UI and for counting
    const projectSources = grounded.filter(s => {
      if (!projectHost) return true; // no project domain set => keep all
      const candidateHost =
        s.resolvedUri ? normalizeHost(s.resolvedUri)
        : s.domain ? s.domain.toLowerCase().replace(/^www\./, '')
        : s.redirectUri ? normalizeHost(s.redirectUri)
        : '';
      return candidateHost && isProjectDomain(candidateHost);
    }).map(s => ({
      redirectUri: s.redirectUri,
      resolvedUri: s.resolvedUri ?? null,
      domain: s.domain ?? null,
      title: s.title ?? null,
    }));

    // 3) Final citation count is based on the filtered subset
    const citationCount = projectSources.length;

    // --- END patched sources & citation logic ---
    
    // ---- write RUNS at /projects/{projectId}/runs
    const runsCollectionRef = db.collection('projects').doc(input.projectId).collection('runs');
    await runsCollectionRef.add({
      projectId: input.projectId,            // REQUIRED by rules
      promptId: input.promptId,              // for join
      response: llmResponseText,
      mentions: analysis.mentions.length,
      citations: citationCount,
      grounded: !!groundingMeta,
      webSearchQueries: groundingMeta?.webSearchQueries ?? [],
      sources: projectSources,
      allSources,
      searchEntryPointHtml: groundingMeta?.searchEntryPoint?.renderedContent ?? null,
      createdAt: new Date(),
    });

    // ---- update PROMPT doc (stays where it is)
    const promptDocRef = db.collection('projects').doc(input.projectId).collection('prompts').doc(input.promptId);
    await promptDocRef.update({
      mentions: analysis.mentions.length,
      citations: citationCount,
      lastRun: new Date(),
    });

    return analysis;
  }
);
created from staging ground