Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
145 changes: 86 additions & 59 deletions e2e-chatbot-app-next/client/src/components/chat-header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { useWindowSize } from 'usehooks-ts';
import { SidebarToggle } from '@/components/sidebar-toggle';
import { Button } from '@/components/ui/button';
import { useSidebar } from './ui/sidebar';
import { PlusIcon, CloudOffIcon, MessageSquareOff } from 'lucide-react';
import { PlusIcon, CloudOffIcon, MessageSquareOff, TriangleAlert } from 'lucide-react';
import { useConfig } from '@/hooks/use-config';
import {
Tooltip,
Expand All @@ -16,72 +16,99 @@ import {
const DOCS_URL =
'https://docs.databricks.com/aws/en/generative-ai/agent-framework/chat-app';

const OBO_DOCS_URL =
'https://docs.databricks.com/aws/en/dev-tools/databricks-apps/auth';
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

let's update this to the new page you made in https://github.com/databricks-eng/universe/pull/1687597


export function ChatHeader() {
const navigate = useNavigate();
const { open } = useSidebar();
const { chatHistoryEnabled, feedbackEnabled } = useConfig();
const { chatHistoryEnabled, feedbackEnabled, oboMissingScopes } = useConfig();

const { width: windowWidth } = useWindowSize();

return (
<header className="sticky top-0 flex items-center gap-2 bg-background px-2 py-1.5 md:px-2">
<SidebarToggle />

{(!open || windowWidth < 768) && (
<Button
variant="outline"
className="order-2 ml-auto h-8 px-2 md:order-1 md:ml-0 md:h-fit md:px-2"
onClick={() => {
navigate('/');
}}
>
<PlusIcon />
<span className="md:sr-only">New Chat</span>
</Button>
)}
<>
<header className="sticky top-0 flex items-center gap-2 bg-background px-2 py-1.5 md:px-2">
<SidebarToggle />

<div className="ml-auto flex items-center gap-2">
{!chatHistoryEnabled && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<a
href={DOCS_URL}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1.5 rounded-full bg-muted px-2 py-1 text-muted-foreground text-xs hover:text-foreground"
>
<CloudOffIcon className="h-3 w-3" />
<span className="hidden sm:inline">Ephemeral</span>
</a>
</TooltipTrigger>
<TooltipContent>
<p>Chat history disabled — conversations are not saved. Click to learn more.</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{!feedbackEnabled && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<a
href={DOCS_URL}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1.5 rounded-full bg-muted px-2 py-1 text-muted-foreground text-xs hover:text-foreground"
>
<MessageSquareOff className="h-3 w-3" />
<span className="hidden sm:inline">Feedback disabled</span>
</a>
</TooltipTrigger>
<TooltipContent>
<p>Feedback submission disabled. Click to learn more.</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
{(!open || windowWidth < 768) && (
<Button
variant="outline"
className="order-2 ml-auto h-8 px-2 md:order-1 md:ml-0 md:h-fit md:px-2"
onClick={() => {
navigate('/');
}}
>
<PlusIcon />
<span className="md:sr-only">New Chat</span>
</Button>
)}
</div>
</header>

<div className="ml-auto flex items-center gap-2">
{!chatHistoryEnabled && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<a
href={DOCS_URL}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1.5 rounded-full bg-muted px-2 py-1 text-muted-foreground text-xs hover:text-foreground"
>
<CloudOffIcon className="h-3 w-3" />
<span className="hidden sm:inline">Ephemeral</span>
</a>
</TooltipTrigger>
<TooltipContent>
<p>Chat history disabled — conversations are not saved. Click to learn more.</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{!feedbackEnabled && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<a
href={DOCS_URL}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1.5 rounded-full bg-muted px-2 py-1 text-muted-foreground text-xs hover:text-foreground"
>
<MessageSquareOff className="h-3 w-3" />
<span className="hidden sm:inline">Feedback disabled</span>
</a>
</TooltipTrigger>
<TooltipContent>
<p>Feedback submission disabled. Click to learn more.</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
</header>

{oboMissingScopes.length > 0 && (
<div className="w-full border-b border-red-500/20 bg-red-50 dark:bg-red-950/20 px-4 py-2.5">
<div className="flex items-center gap-2">
<TriangleAlert className="h-4 w-4 shrink-0 text-red-600 dark:text-red-400" />
<p className="text-sm text-red-700 dark:text-red-400">
This endpoint requires on-behalf-of user authorization. Add these
scopes to your app:{' '}
<strong>{oboMissingScopes.join(', ')}</strong>.{' '}
Note: UC function scopes are not yet supported.{' '}
<a
href={OBO_DOCS_URL}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 underline hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300"
>
Learn more
</a>
</p>
</div>
</div>
)}
</>
);
}
14 changes: 14 additions & 0 deletions e2e-chatbot-app-next/client/src/contexts/AppConfigContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@ interface ConfigResponse {
chatHistory: boolean;
feedback: boolean;
};
obo?: {
enabled: boolean;
requiredScopes: string[];
missingScopes: string[];
isSupervisorAgent: boolean;
};
}

interface AppConfigContextType {
Expand All @@ -15,6 +21,10 @@ interface AppConfigContextType {
error: Error | undefined;
chatHistoryEnabled: boolean;
feedbackEnabled: boolean;
oboEnabled: boolean;
oboRequiredScopes: string[];
oboMissingScopes: string[];
oboIsSupervisorAgent: boolean;
}

const AppConfigContext = createContext<AppConfigContextType | undefined>(
Expand All @@ -40,6 +50,10 @@ export function AppConfigProvider({ children }: { children: ReactNode }) {
// Default to true until loaded to avoid breaking existing behavior
chatHistoryEnabled: data?.features.chatHistory ?? true,
feedbackEnabled: data?.features.feedback ?? false,
oboEnabled: data?.obo?.enabled ?? false,
oboRequiredScopes: data?.obo?.requiredScopes ?? [],
oboMissingScopes: data?.obo?.missingScopes ?? [],
oboIsSupervisorAgent: data?.obo?.isSupervisorAgent ?? false,
};

return (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,13 +73,16 @@ const LOG_SSE_EVENTS = process.env.LOG_SSE_EVENTS === 'true';

const API_PROXY = process.env.API_PROXY;

// Cache for endpoint details to check task type
// Cache for endpoint details to check task type and OBO scopes
const endpointDetailsCache = new Map<
string,
{ task: string | undefined; timestamp: number }
{ task: string | undefined; userApiScopes: string[]; isOboEnabled: boolean; timestamp: number }
>();
const ENDPOINT_DETAILS_CACHE_DURATION = 5 * 60 * 1000; // 5 minutes

// Cached OBO status — set by getEndpointDetails, read by the provider fetch
let cachedOboEnabled = false;

/**
* Checks if context should be injected based on cached endpoint details.
* Returns true if API_PROXY is set or if the endpoint task type is agent/v2/chat or agent/v1/responses.
Expand Down Expand Up @@ -251,10 +254,20 @@ async function getOrCreateDatabricksProvider(): Promise<CachedProvider> {
baseURL: `${hostname}/serving-endpoints`,
formatUrl: ({ baseUrl, path }) => API_PROXY ?? `${baseUrl}${path}`,
fetch: async (...[input, init]: Parameters<typeof fetch>) => {
// Always get fresh token for each request (will use cache if valid)
const currentToken = await getProviderToken();
const headers = new Headers(init?.headers);
headers.set('Authorization', `Bearer ${currentToken}`);

// If the user's OBO token is present and the endpoint supports OBO,
// use the user's token for Authorization so the endpoint sees the
// user's identity for on-behalf-of authorization.
const userToken = headers.get('x-forwarded-access-token');
if (userToken && cachedOboEnabled) {
headers.set('Authorization', `Bearer ${userToken}`);
headers.delete('x-forwarded-access-token');
} else {
const currentToken = await getProviderToken();
headers.set('Authorization', `Bearer ${currentToken}`);
}

if (API_PROXY) {
headers.set('x-mlflow-return-trace-id', 'true');
}
Expand All @@ -271,7 +284,20 @@ async function getOrCreateDatabricksProvider(): Promise<CachedProvider> {
return provider;
}

// Get the task type of the serving endpoint
// Response type for serving endpoint details
interface EndpointDetailsResponse {
task: string | undefined;
auth_policy?: {
user_auth_policy: {
api_scopes: string[];
};
};
tile_endpoint_metadata?: {
problem_type: string;
};
}

// Get the task type and OBO scopes of the serving endpoint
const getEndpointDetails = async (servingEndpoint: string) => {
const cached = endpointDetailsCache.get(servingEndpoint);
if (
Expand All @@ -294,15 +320,57 @@ const getEndpointDetails = async (servingEndpoint: string) => {
headers,
},
);
const data = (await response.json()) as { task: string | undefined };
const data = (await response.json()) as EndpointDetailsResponse;

// Detect OBO: either explicit auth_policy scopes, or Supervisor Agent (always OBO)
const isSupervisorAgent = data.tile_endpoint_metadata?.problem_type === 'MULTI_AGENT_SUPERVISOR';
const userApiScopes = data.auth_policy?.user_auth_policy?.api_scopes ?? [];
const isOboEnabled = userApiScopes.length > 0 || isSupervisorAgent;
cachedOboEnabled = isOboEnabled;

// serving.serving-endpoints is always needed for OBO (to call the endpoint as the user)
if (isOboEnabled && !userApiScopes.includes('serving.serving-endpoints')) {
userApiScopes.push('serving.serving-endpoints');
}

if (isOboEnabled) {
console.warn(
`⚠ OBO detected on endpoint "${servingEndpoint}". Required user authorization scopes: ${JSON.stringify(userApiScopes)}\n` +
` → Add scopes to your app via the Databricks UI or in databricks.yml\n` +
` → Note: UC function scopes are not yet supported.\n` +
` → See: https://docs.databricks.com/aws/en/dev-tools/databricks-apps/auth`,
);
}

const returnValue = {
task: data.task as string | undefined,
userApiScopes,
isOboEnabled,
timestamp: Date.now(),
};
endpointDetailsCache.set(servingEndpoint, returnValue);
return returnValue;
};

/**
* Returns OBO info for the configured serving endpoint.
* Detects OBO via auth_policy scopes or Supervisor Agent type.
*/
export async function getEndpointOboInfo(): Promise<{ enabled: boolean; requiredScopes: string[]; isSupervisorAgent: boolean }> {
const servingEndpoint = process.env.DATABRICKS_SERVING_ENDPOINT;
if (!servingEndpoint) return { enabled: false, requiredScopes: [], isSupervisorAgent: false };
try {
const details = await getEndpointDetails(servingEndpoint);
return {
enabled: details.isOboEnabled,
requiredScopes: details.userApiScopes,
isSupervisorAgent: details.isOboEnabled && details.userApiScopes.length === 0,
};
} catch {
return { enabled: false, requiredScopes: [], isSupervisorAgent: false };
}
}

// Create a smart provider wrapper that handles OAuth initialization
interface SmartProvider {
languageModel(id: string): Promise<LanguageModelV3>;
Expand Down
50 changes: 48 additions & 2 deletions e2e-chatbot-app-next/server/src/routes/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,18 +5,64 @@ import {
type Router as RouterType,
} from 'express';
import { isDatabaseAvailable } from '@chat-template/db';
import { getEndpointOboInfo } from '@chat-template/ai-sdk-providers';

export const configRouter: RouterType = Router();

/**
* Decode a JWT payload without verification (just reads claims).
* Returns the parsed payload or null if decoding fails.
*/
function decodeJwtPayload(token: string): Record<string, unknown> | null {
try {
const parts = token.split('.');
if (parts.length !== 3) return null;
const decoded = Buffer.from(parts[1], 'base64url').toString('utf-8');
return JSON.parse(decoded);
} catch {
return null;
}
}

/**
* GET /api/config - Get application configuration
* Returns feature flags based on environment configuration
* Returns feature flags and OBO status based on environment configuration.
* If the user's OBO token is present, decodes it to check which required
* scopes are missing — the banner only shows missing scopes.
*/
configRouter.get('/', (_req: Request, res: Response) => {
configRouter.get('/', async (req: Request, res: Response) => {
const oboInfo = await getEndpointOboInfo();

let missingScopes = oboInfo.requiredScopes;

// If the user has an OBO token, check which scopes are already present
const userToken = req.headers['x-forwarded-access-token'] as string | undefined;
if (userToken && oboInfo.enabled) {
const payload = decodeJwtPayload(userToken);
if (payload) {
// Databricks OAuth tokens use 'scope' (space-separated string)
const tokenScopes = typeof payload.scope === 'string'
? payload.scope.split(' ')
: Array.isArray(payload.scp) ? payload.scp as string[] : [];
// A required scope like "sql.statement-execution" is satisfied by
// an exact match OR by its parent prefix (e.g. "sql")
missingScopes = oboInfo.requiredScopes.filter(required => {
const parent = required.split('.')[0];
return !tokenScopes.some(ts => ts === required || ts === parent);
});
}
}

res.json({
features: {
chatHistory: isDatabaseAvailable(),
feedback: !!process.env.MLFLOW_EXPERIMENT_ID,
},
obo: {
enabled: oboInfo.enabled,
requiredScopes: oboInfo.requiredScopes,
missingScopes,
isSupervisorAgent: oboInfo.isSupervisorAgent,
},
});
});
Loading