Compare commits

...

3 Commits

Author SHA1 Message Date
Steven H f8b2a1757a Merge branch 'main' into stevenh/ask-original-user-agent 2026-10-28 11:59:55 +00:00
Steven H 867cc4d6a9 changeset
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 09:16:01 +00:00
Steven H 87d61c6f29 Forward the visitor's user agent on ask and Assistant calls
Send the visitor's User-Agent and Signature-Agent as X-Original-User-Agent and
X-Original-Signature-Agent on streamAskInSite and streamAiResponseInSite, so the
API classifies who asked (human, agent, bot) instead of seeing the published
site's own user agent. Covers the Assistant, search ask, ?ask= and the MCP ask tool.

The ?ask= route is static, so middleware passes the headers through the route's
site data, splitting its cache per user agent.

Depends on GitbookIO/gitbook-x#26802.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 09:15:57 +00:00
12 changed files with 149 additions and 8 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Forward the visitor's user agent on ask and Assistant calls so questions are classified by who asked.
@@ -1,6 +1,10 @@
import type { NextRequest } from 'next/server';
import { type RouteLayoutParams, getStaticSiteContext } from '@/app/utils';
import {
type RouteLayoutParams,
getSiteURLDataFromParams,
getStaticSiteContext,
} from '@/app/utils';
import { serveAskMarkdown } from '@/routes/markdownAsk';
export const dynamic = 'force-static';
@@ -18,8 +22,9 @@ export async function GET(
) {
const { question: encodedQuestion, goal: encodedGoal } = await params;
const { context } = await getStaticSiteContext(await params);
const { askVisitorAgent } = getSiteURLDataFromParams(await params);
const question = decodeURIComponent(encodedQuestion);
const goal = decodeURIComponent(encodedGoal);
return serveAskMarkdown(context, question, { goal });
return serveAskMarkdown(context, question, { goal, visitorAgent: askVisitorAgent });
}
@@ -1,6 +1,10 @@
import type { NextRequest } from 'next/server';
import { type RouteLayoutParams, getStaticSiteContext } from '@/app/utils';
import {
type RouteLayoutParams,
getSiteURLDataFromParams,
getStaticSiteContext,
} from '@/app/utils';
import { serveAskMarkdown } from '@/routes/markdownAsk';
export const dynamic = 'force-static';
@@ -11,7 +15,8 @@ export async function GET(
) {
const { question: encodedQuestion } = await params;
const { context } = await getStaticSiteContext(await params);
const { askVisitorAgent } = getSiteURLDataFromParams(await params);
const question = decodeURIComponent(encodedQuestion);
return serveAskMarkdown(context, question);
return serveAskMarkdown(context, question, { visitorAgent: askVisitorAgent });
}
@@ -1,4 +1,6 @@
'use server';
import { headers } from 'next/headers';
import {
type AIMessageContext,
AIMessageRole,
@@ -12,6 +14,7 @@ import {
import { streamRenderAIMessage } from './api';
import type { RenderAIMessageOptions } from './types';
import { isAIChatEnabled } from '@/components/utils/isAIChatEnabled';
import { getAskForwardedHeaders, getAskVisitorAgent } from '@/lib/ask-visitor';
import { getSiteURLDataFromMiddleware } from '@/lib/middleware';
import { fetchServerActionSiteContext, getServerActionBaseContext } from '@/lib/server-actions';
import { traceErrorOnly } from '@/lib/tracing';
@@ -72,7 +75,8 @@ export async function* streamAIChatResponse({
? SiteCoreChannelType.Embed
: SiteCoreChannelType.Site,
},
}
},
{ headers: getAskForwardedHeaders(getAskVisitorAgent(await headers())) }
);
return await streamRenderAIMessage(context, rawStream, options);
@@ -1,6 +1,7 @@
'use server';
import { createStreamableValue } from 'ai/rsc';
import { headers } from 'next/headers';
import type * as React from 'react';
import type {
@@ -13,6 +14,7 @@ import type {
import { DocumentView } from '../DocumentView';
import { isAIEnabled, isAISearchEnabled } from '@/components/utils/isAIChatEnabled';
import { getAskForwardedHeaders, getAskVisitorAgent } from '@/lib/ask-visitor';
import type { GitBookSiteContext } from '@/lib/context';
import { throwIfDataError } from '@/lib/data';
import { toEmbeddableLinkForPublishedContent } from '@/lib/embeddable-linker';
@@ -77,7 +79,8 @@ export async function streamAskQuestion({
},
session,
},
{ format: 'document' }
{ format: 'document' },
{ headers: getAskForwardedHeaders(getAskVisitorAgent(await headers())) }
);
const spacePromises = new Map<string, Promise<Revision>>();
@@ -0,0 +1,48 @@
import { describe, expect, it } from 'bun:test';
import { getAskForwardedHeaders, getAskVisitorAgent } from './ask-visitor';
describe('getAskVisitorAgent', () => {
it('should read the User-Agent and Signature-Agent headers', () => {
expect(
getAskVisitorAgent(
new Headers({
'user-agent': 'claude-code/2.0.0',
'signature-agent': '"https://chatgpt.com"',
})
)
).toEqual({ userAgent: 'claude-code/2.0.0', signatureAgent: '"https://chatgpt.com"' });
});
it('should omit missing or empty headers', () => {
const agent = getAskVisitorAgent(new Headers({ 'user-agent': '' }));
expect(agent).toEqual({});
expect('userAgent' in agent).toBe(false);
});
it('should truncate overly long headers', () => {
const agent = getAskVisitorAgent(new Headers({ 'user-agent': 'a'.repeat(2000) }));
expect(agent.userAgent).toHaveLength(512);
});
});
describe('getAskForwardedHeaders', () => {
it('should forward the visitor agent in X-Original headers', () => {
expect(
getAskForwardedHeaders({
userAgent: 'claude-code/2.0.0',
signatureAgent: '"https://chatgpt.com"',
})
).toEqual({
'X-Original-User-Agent': 'claude-code/2.0.0',
'X-Original-Signature-Agent': '"https://chatgpt.com"',
});
});
it('should omit headers the visitor did not send', () => {
expect(getAskForwardedHeaders({ userAgent: 'curl/8.7.1' })).toEqual({
'X-Original-User-Agent': 'curl/8.7.1',
});
expect(getAskForwardedHeaders()).toEqual({});
});
});
+36
View File
@@ -0,0 +1,36 @@
/**
* Headers identifying who asked a question, forwarded so the API can classify the asker
* (human, agent, bot) the same way it classifies insights events.
*/
export interface AskVisitorAgent {
userAgent?: string;
signatureAgent?: string;
}
// Bounds the header values, as they end up in the static route path of `?ask=`.
const MAX_HEADER_LENGTH = 512;
/**
* Read the visitor's `User-Agent` and `Signature-Agent` from the request headers.
* Missing headers are omitted rather than set to undefined, so the result can be rison-encoded.
*/
export function getAskVisitorAgent(headers: Pick<Headers, 'get'>): AskVisitorAgent {
const userAgent = headers.get('user-agent')?.slice(0, MAX_HEADER_LENGTH);
const signatureAgent = headers.get('signature-agent')?.slice(0, MAX_HEADER_LENGTH);
return {
...(userAgent ? { userAgent } : {}),
...(signatureAgent ? { signatureAgent } : {}),
};
}
/**
* Request headers forwarding the visitor's agent on ask and Assistant API calls.
* Our own `User-Agent` is left as is: the API relies on it to know the published site is calling.
*/
export function getAskForwardedHeaders(agent: AskVisitorAgent = {}): Record<string, string> {
return {
...(agent.userAgent ? { 'X-Original-User-Agent': agent.userAgent } : {}),
...(agent.signatureAgent ? { 'X-Original-Signature-Agent': agent.signatureAgent } : {}),
};
}
+8 -1
View File
@@ -1,5 +1,6 @@
import type { SearchAIAnswer, SearchAIAnswerSource } from '@gitbook/api';
import { type AskVisitorAgent, getAskForwardedHeaders } from '@/lib/ask-visitor';
import type { GitBookSiteContext } from '@/lib/context';
import { throwIfDataError } from '@/lib/data';
import { resolvePageId } from '@/lib/pages';
@@ -15,6 +16,11 @@ export interface StreamSiteAskOptions {
* Used by the backend to steer the answer.
*/
goal?: string;
/**
* Headers of the visitor asking the question, used by the backend to classify the asker.
*/
visitorAgent?: AskVisitorAgent;
}
/**
@@ -45,7 +51,8 @@ export async function streamSiteAskAnswer(
currentSiteSpace: context.siteSpace.id,
},
},
{ format: 'markdown' }
{ format: 'markdown' },
{ headers: getAskForwardedHeaders(options.visitorAgent) }
);
let latestAnswer: SearchAIAnswer | null = null;
+7
View File
@@ -23,6 +23,7 @@ import type {
import { GITBOOK_URL } from './env';
import { type ImageResizer, createImageResizer } from './images';
import { type GitBookLinker, createLinker, linkerForPublishedURL } from './links';
import type { AskVisitorAgent } from '@/lib/ask-visitor';
import {
type GitBookDataFetcher,
createDataFetcher,
@@ -104,6 +105,12 @@ export type SiteURLData = Pick<
/** Whether the request comes from ChatGPT. Only set for Markdown and LLM routes. */
isChatGPT?: boolean;
/**
* Headers of the visitor asking through `?ask=`, forwarded to the API to classify the asker.
* Only set for the ask route, which is static and can't read the request headers.
*/
askVisitorAgent?: AskVisitorAgent;
};
/**
+4
View File
@@ -14,6 +14,7 @@ import {
} from '@/lib/agentFeedback';
import { submitAgentFeedback } from '@/lib/agentFeedback/server';
import { renderAskSourcesMarkdown, streamSiteAskAnswer } from '@/lib/ask';
import { getAskVisitorAgent } from '@/lib/ask-visitor';
import type { GitBookSiteContext } from '@/lib/context';
import { getExposableError, throwIfDataError } from '@/lib/data';
import { fromPageMarkdown, getMarkdownForPageInSpace, toPageMarkdown } from '@/lib/markdownPage';
@@ -279,8 +280,11 @@ export function createSiteMcpTools(
const trimmedGoal = goal?.trim() || undefined;
// The MCP server is stateless, so the `clientInfo` from `initialize` isn't
// available here: rely on the HTTP user agent, as the `ask_question` event does.
const answer = await streamSiteAskAnswer(context, trimmedQuestion, {
goal: trimmedGoal,
visitorAgent: getAskVisitorAgent(request.headers),
});
trackMcpEvent({
+8
View File
@@ -23,6 +23,7 @@ import {
} from './lib/tracking';
import { AI_CATALOG_PATH, AI_CATALOG_WELL_KNOWN_PATH } from '@/lib/aiCatalog/paths';
import { getAPITokenFromCookies, getAPITokenResponseCookies } from '@/lib/api-token-cookie';
import { type AskVisitorAgent, getAskVisitorAgent } from '@/lib/ask-visitor';
import { isChatGPTRequest } from '@/lib/chatgpt';
import { MAX_CHUNKED_COOKIE_LENGTH } from '@/lib/chunked-cookies';
import type { SiteURLData } from '@/lib/context';
@@ -470,11 +471,14 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) {
events,
isAiAgent,
isChatGPT,
askVisitorAgent,
} = encodePathInSiteContent(siteURLData, request);
routeType = routeTypeFromPathname ?? routeType;
// Only set for Markdown and LLM routes, so these request-specific variants are cached separately.
stableSiteURLData.isAiAgent = isAiAgent;
stableSiteURLData.isChatGPT = isChatGPT;
// Splits the cache of `?ask=` answers per user agent, so each asker is classified correctly.
stableSiteURLData.askVisitorAgent = askVisitorAgent;
// Apply a forced theme (`?theme=`/cookie). For the docs embed we thread it through the
// route context (`embedTheme`) so those routes stay statically rendered — it becomes part
@@ -800,6 +804,8 @@ function encodePathInSiteContent(
isAiAgent?: boolean;
/** Only set for Markdown and LLM routes, where the output content type depends on ChatGPT. */
isChatGPT?: boolean;
/** Only set for the ask route, where the API classifies the asker from these headers. */
askVisitorAgent?: AskVisitorAgent;
} {
let pathname = removeLeadingSlash(removeTrailingSlash(siteURLData.pathname));
@@ -941,6 +947,8 @@ function encodePathInSiteContent(
// Left undefined for non-agents to avoid splitting the static cache for them.
isAiAgent: isAiAgent || undefined,
isChatGPT: isChatGPTRequest(request) || undefined,
askVisitorAgent:
typeof ask === 'string' ? getAskVisitorAgent(request.headers) : undefined,
// TODO: track pageId / spaceId when possible
// We don't do it at the moment as we can't easily extract it from the URL.
events: ask
+10 -1
View File
@@ -1,5 +1,6 @@
import { isAIEnabled } from '@/components/utils/isAIChatEnabled';
import { renderAskSourcesMarkdown, streamSiteAskAnswer } from '@/lib/ask';
import type { AskVisitorAgent } from '@/lib/ask-visitor';
import type { GitBookSiteContext } from '@/lib/context';
import { linkerWithMarkdownPages } from '@/lib/links';
import { fromPageMarkdown, toPageMarkdown } from '@/lib/markdownPage';
@@ -14,6 +15,11 @@ export interface ServeAskMarkdownOptions {
* passed via the `?goal=` search parameter. Used by the backend to steer the answer.
*/
goal?: string;
/**
* Headers of the visitor asking the question, forwarded to the backend to classify the asker.
*/
visitorAgent?: AskVisitorAgent;
}
/**
@@ -41,7 +47,10 @@ export async function serveAskMarkdown(
return 'You forgot to pass a question in the `?ask=` parameter. Append a question to the URL in the `?ask=<question>` search parameter to get a complete answer and associated sources.';
}
const latestAnswer = await streamSiteAskAnswer(context, question, { goal });
const latestAnswer = await streamSiteAskAnswer(context, question, {
goal,
visitorAgent: options.visitorAgent,
});
if (!latestAnswer || !latestAnswer.answer || !('markdown' in latestAnswer.answer)) {
return `We couldn't answer this question.`;