farooquiowais Cursor commited on
Commit
cd036d7
·
1 Parent(s): d059023

Resolve Gemini grounding redirect citation URLs

Browse files

Co-authored-by: Cursor <cursoragent@cursor.com>

src/cloudcode/message-handler.js CHANGED
@@ -19,6 +19,7 @@ import {
19
  isThinkingModel
20
  } from '../constants.js';
21
  import { convertGoogleToAnthropic } from '../format/index.js';
 
22
  import { isRateLimitError, isAuthError, isAccountForbiddenError, AccountForbiddenError } from '../errors.js';
23
  import { formatDuration, sleep, isNetworkError, throttledFetch } from '../utils/helpers.js';
24
  import { logger } from '../utils/logger.js';
@@ -335,7 +336,11 @@ export async function sendMessage(anthropicRequest, accountManager, fallbackEnab
335
  // Clear rate limit state on success
336
  clearRateLimitState(account.email, model);
337
  accountManager.notifySuccess(account, model);
338
- return convertGoogleToAnthropic(data, anthropicRequest.model);
 
 
 
 
339
 
340
  } catch (endpointError) {
341
  if (isRateLimitError(endpointError)) {
 
19
  isThinkingModel
20
  } from '../constants.js';
21
  import { convertGoogleToAnthropic } from '../format/index.js';
22
+ import { resolveGroundingRedirects } from '../format/grounding.js';
23
  import { isRateLimitError, isAuthError, isAccountForbiddenError, AccountForbiddenError } from '../errors.js';
24
  import { formatDuration, sleep, isNetworkError, throttledFetch } from '../utils/helpers.js';
25
  import { logger } from '../utils/logger.js';
 
336
  // Clear rate limit state on success
337
  clearRateLimitState(account.email, model);
338
  accountManager.notifySuccess(account, model);
339
+ const converted = convertGoogleToAnthropic(data, anthropicRequest.model);
340
+ if (converted.grounding) {
341
+ converted.grounding = await resolveGroundingRedirects(converted.grounding);
342
+ }
343
+ return converted;
344
 
345
  } catch (endpointError) {
346
  if (isRateLimitError(endpointError)) {
src/cloudcode/sse-parser.js CHANGED
@@ -6,6 +6,7 @@
6
  */
7
 
8
  import { convertGoogleToAnthropic } from '../format/index.js';
 
9
  import { logger } from '../utils/logger.js';
10
 
11
  /**
@@ -125,5 +126,9 @@ export async function parseThinkingSSEResponse(response, originalModel) {
125
  logger.debug('[CloudCode] Thinking signature length:', thinkingPart?.thoughtSignature?.length || 0);
126
  }
127
 
128
- return convertGoogleToAnthropic(accumulatedResponse, originalModel);
 
 
 
 
129
  }
 
6
  */
7
 
8
  import { convertGoogleToAnthropic } from '../format/index.js';
9
+ import { resolveGroundingRedirects } from '../format/grounding.js';
10
  import { logger } from '../utils/logger.js';
11
 
12
  /**
 
126
  logger.debug('[CloudCode] Thinking signature length:', thinkingPart?.thoughtSignature?.length || 0);
127
  }
128
 
129
+ const converted = convertGoogleToAnthropic(accumulatedResponse, originalModel);
130
+ if (converted.grounding) {
131
+ converted.grounding = await resolveGroundingRedirects(converted.grounding);
132
+ }
133
+ return converted;
134
  }
src/cloudcode/sse-streamer.js CHANGED
@@ -9,7 +9,7 @@ import crypto from 'crypto';
9
  import { MIN_SIGNATURE_LENGTH, getModelFamily } from '../constants.js';
10
  import { EmptyResponseError } from '../errors.js';
11
  import { cacheSignature, cacheThinkingSignature } from '../format/signature-cache.js';
12
- import { extractGrounding } from '../format/grounding.js';
13
  import { logger } from '../utils/logger.js';
14
 
15
  /**
@@ -287,7 +287,7 @@ export async function* streamSSEResponse(response, originalModel) {
287
  }
288
 
289
  // Emit message_delta and message_stop
290
- const grounding = extractGrounding(groundingMetadata);
291
  if (grounding) {
292
  logger.info(`[CloudCode] grounding captured (stream): ${grounding.sources.length} source(s), ${grounding.annotations.length} citation(s)`);
293
  }
 
9
  import { MIN_SIGNATURE_LENGTH, getModelFamily } from '../constants.js';
10
  import { EmptyResponseError } from '../errors.js';
11
  import { cacheSignature, cacheThinkingSignature } from '../format/signature-cache.js';
12
+ import { extractGrounding, resolveGroundingRedirects } from '../format/grounding.js';
13
  import { logger } from '../utils/logger.js';
14
 
15
  /**
 
287
  }
288
 
289
  // Emit message_delta and message_stop
290
+ const grounding = await resolveGroundingRedirects(extractGrounding(groundingMetadata));
291
  if (grounding) {
292
  logger.info(`[CloudCode] grounding captured (stream): ${grounding.sources.length} source(s), ${grounding.annotations.length} citation(s)`);
293
  }
src/format/grounding.js CHANGED
@@ -13,6 +13,13 @@
13
  * OpenAI-compatible clients can consume citations with no special-casing.
14
  */
15
 
 
 
 
 
 
 
 
16
  function pickUri(chunk) {
17
  return chunk?.web?.uri || chunk?.retrievedContext?.uri || null;
18
  }
@@ -21,6 +28,119 @@ function pickTitle(chunk) {
21
  return chunk?.web?.title || chunk?.retrievedContext?.title || '';
22
  }
23
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
24
  export function extractGrounding(groundingMetadata) {
25
  if (!groundingMetadata || typeof groundingMetadata !== 'object') return null;
26
 
 
13
  * OpenAI-compatible clients can consume citations with no special-casing.
14
  */
15
 
16
+ const GROUNDING_REDIRECT_HOST = 'vertexaisearch.cloud.google.com';
17
+ const GROUNDING_REDIRECT_TIMEOUT_MS = Number(process.env.GROUNDING_REDIRECT_TIMEOUT_MS || 1500);
18
+ const GROUNDING_REDIRECT_CACHE_TTL_MS = Number(process.env.GROUNDING_REDIRECT_CACHE_TTL_MS || 24 * 60 * 60 * 1000);
19
+ const GROUNDING_REDIRECT_CACHE_MAX = Number(process.env.GROUNDING_REDIRECT_CACHE_MAX || 1000);
20
+
21
+ const redirectCache = new Map();
22
+
23
  function pickUri(chunk) {
24
  return chunk?.web?.uri || chunk?.retrievedContext?.uri || null;
25
  }
 
28
  return chunk?.web?.title || chunk?.retrievedContext?.title || '';
29
  }
30
 
31
+ function isVertexGroundingRedirect(rawUrl) {
32
+ try {
33
+ const parsed = new URL(rawUrl);
34
+ return parsed.hostname === GROUNDING_REDIRECT_HOST
35
+ && parsed.pathname.includes('/grounding-api-redirect/');
36
+ } catch {
37
+ return false;
38
+ }
39
+ }
40
+
41
+ function getCachedRedirect(rawUrl) {
42
+ const row = redirectCache.get(rawUrl);
43
+ if (!row) return null;
44
+ if (Date.now() > row.expiresAt) {
45
+ redirectCache.delete(rawUrl);
46
+ return null;
47
+ }
48
+ return row.url;
49
+ }
50
+
51
+ function setCachedRedirect(rawUrl, resolvedUrl) {
52
+ if (redirectCache.size >= GROUNDING_REDIRECT_CACHE_MAX) {
53
+ const firstKey = redirectCache.keys().next().value;
54
+ if (firstKey) redirectCache.delete(firstKey);
55
+ }
56
+ redirectCache.set(rawUrl, {
57
+ url: resolvedUrl,
58
+ expiresAt: Date.now() + GROUNDING_REDIRECT_CACHE_TTL_MS
59
+ });
60
+ }
61
+
62
+ async function readRedirectLocation(rawUrl, method) {
63
+ const response = await fetch(rawUrl, {
64
+ method,
65
+ redirect: 'manual',
66
+ signal: AbortSignal.timeout(GROUNDING_REDIRECT_TIMEOUT_MS)
67
+ });
68
+
69
+ const location = response.headers.get('location');
70
+ if (!location) return null;
71
+
72
+ try {
73
+ return new URL(location, rawUrl).toString();
74
+ } catch {
75
+ return location;
76
+ }
77
+ }
78
+
79
+ export async function resolveCitationUrl(rawUrl) {
80
+ if (!rawUrl || !isVertexGroundingRedirect(rawUrl)) return rawUrl;
81
+
82
+ const cached = getCachedRedirect(rawUrl);
83
+ if (cached) return cached;
84
+
85
+ let resolvedUrl = rawUrl;
86
+ try {
87
+ resolvedUrl = await readRedirectLocation(rawUrl, 'HEAD') || rawUrl;
88
+ } catch {
89
+ try {
90
+ resolvedUrl = await readRedirectLocation(rawUrl, 'GET') || rawUrl;
91
+ } catch {
92
+ resolvedUrl = rawUrl;
93
+ }
94
+ }
95
+
96
+ setCachedRedirect(rawUrl, resolvedUrl);
97
+ return resolvedUrl;
98
+ }
99
+
100
+ export async function resolveGroundingRedirects(grounding) {
101
+ if (!grounding) return grounding;
102
+
103
+ const urls = new Set();
104
+ for (const source of grounding.sources || []) {
105
+ if (source?.raw_url) urls.add(source.raw_url);
106
+ if (source?.url) urls.add(source.url);
107
+ }
108
+ for (const ann of grounding.annotations || []) {
109
+ const rawUrl = ann?.url_citation?.raw_url;
110
+ const url = ann?.url_citation?.url;
111
+ if (rawUrl) urls.add(rawUrl);
112
+ if (url) urls.add(url);
113
+ }
114
+
115
+ const pairs = await Promise.all(
116
+ [...urls].map(async (url) => [url, await resolveCitationUrl(url)])
117
+ );
118
+ const resolvedByRawUrl = new Map(pairs);
119
+
120
+ return {
121
+ ...grounding,
122
+ sources: (grounding.sources || []).map((source) => {
123
+ const rawUrl = source.raw_url || source.url;
124
+ return {
125
+ ...source,
126
+ raw_url: rawUrl,
127
+ url: resolvedByRawUrl.get(rawUrl) || resolvedByRawUrl.get(source.url) || source.url
128
+ };
129
+ }),
130
+ annotations: (grounding.annotations || []).map((ann) => {
131
+ const rawUrl = ann.url_citation.raw_url || ann.url_citation.url;
132
+ return {
133
+ ...ann,
134
+ url_citation: {
135
+ ...ann.url_citation,
136
+ raw_url: rawUrl,
137
+ url: resolvedByRawUrl.get(rawUrl) || resolvedByRawUrl.get(ann.url_citation.url) || ann.url_citation.url
138
+ }
139
+ };
140
+ })
141
+ };
142
+ }
143
+
144
  export function extractGrounding(groundingMetadata) {
145
  if (!groundingMetadata || typeof groundingMetadata !== 'object') return null;
146
 
tests/migration/grounding-redirects.test.js ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import assert from 'node:assert/strict';
2
+ import test from 'node:test';
3
+ import { resolveGroundingRedirects } from '../../src/format/grounding.js';
4
+
5
+ test('resolveGroundingRedirects converts Vertex grounding redirect URLs to final URLs', async () => {
6
+ const originalFetch = globalThis.fetch;
7
+ globalThis.fetch = async (url, options) => {
8
+ assert.equal(options.redirect, 'manual');
9
+ assert.ok(['HEAD', 'GET'].includes(options.method));
10
+ return new Response('', {
11
+ status: 302,
12
+ headers: {
13
+ location: 'https://www.kraken.com/prices/bitcoin'
14
+ }
15
+ });
16
+ };
17
+
18
+ try {
19
+ const rawUrl = 'https://vertexaisearch.cloud.google.com/grounding-api-redirect/abc';
20
+ const grounding = await resolveGroundingRedirects({
21
+ queries: ['bitcoin price'],
22
+ sources: [{ url: rawUrl, title: 'kraken.com' }],
23
+ annotations: [{
24
+ type: 'url_citation',
25
+ url_citation: {
26
+ url: rawUrl,
27
+ title: 'kraken.com',
28
+ start_index: 0,
29
+ end_index: 42
30
+ }
31
+ }]
32
+ });
33
+
34
+ assert.equal(grounding.sources[0].url, 'https://www.kraken.com/prices/bitcoin');
35
+ assert.equal(grounding.sources[0].raw_url, rawUrl);
36
+ assert.equal(grounding.annotations[0].url_citation.url, 'https://www.kraken.com/prices/bitcoin');
37
+ assert.equal(grounding.annotations[0].url_citation.raw_url, rawUrl);
38
+ } finally {
39
+ globalThis.fetch = originalFetch;
40
+ }
41
+ });
42
+
43
+ test('resolveGroundingRedirects leaves non-Vertex URLs unchanged', async () => {
44
+ const url = 'https://example.com/page';
45
+ const grounding = await resolveGroundingRedirects({
46
+ queries: [],
47
+ sources: [{ url, title: 'example.com' }],
48
+ annotations: [{
49
+ type: 'url_citation',
50
+ url_citation: {
51
+ url,
52
+ title: 'example.com',
53
+ start_index: 0,
54
+ end_index: 10
55
+ }
56
+ }]
57
+ });
58
+
59
+ assert.equal(grounding.sources[0].url, url);
60
+ assert.equal(grounding.annotations[0].url_citation.url, url);
61
+ });