diff --git a/apps/dev-playground/.env.dist b/apps/dev-playground/.env.dist index 23c3265a..80eda94b 100644 --- a/apps/dev-playground/.env.dist +++ b/apps/dev-playground/.env.dist @@ -9,6 +9,7 @@ OTEL_SERVICE_NAME='dev-playground' DATABRICKS_VOLUME_PLAYGROUND= DATABRICKS_VOLUME_OTHER= DATABRICKS_GENIE_SPACE_ID= +DATABRICKS_SERVING_ENDPOINT= LAKEBASE_ENDPOINT='' # Run: databricks postgres list-endpoints projects/{project-id}/branches/{branch-id} — use the `name` field from the output PGHOST= PGUSER= diff --git a/apps/dev-playground/client/.gitignore b/apps/dev-playground/client/.gitignore index a547bf36..267b28f3 100644 --- a/apps/dev-playground/client/.gitignore +++ b/apps/dev-playground/client/.gitignore @@ -12,6 +12,9 @@ dist dist-ssr *.local +# Auto-generated types (endpoint-specific, varies per developer) +src/appKitServingTypes.d.ts + # Editor directories and files .vscode/* !.vscode/extensions.json diff --git a/apps/dev-playground/client/src/routeTree.gen.ts b/apps/dev-playground/client/src/routeTree.gen.ts index c4c38d14..99ac75fc 100644 --- a/apps/dev-playground/client/src/routeTree.gen.ts +++ b/apps/dev-playground/client/src/routeTree.gen.ts @@ -12,6 +12,7 @@ import { Route as rootRouteImport } from './routes/__root' import { Route as TypeSafetyRouteRouteImport } from './routes/type-safety.route' import { Route as TelemetryRouteRouteImport } from './routes/telemetry.route' import { Route as SqlHelpersRouteRouteImport } from './routes/sql-helpers.route' +import { Route as ServingRouteRouteImport } from './routes/serving.route' import { Route as ReconnectRouteRouteImport } from './routes/reconnect.route' import { Route as LakebaseRouteRouteImport } from './routes/lakebase.route' import { Route as GenieRouteRouteImport } from './routes/genie.route' @@ -37,6 +38,11 @@ const SqlHelpersRouteRoute = SqlHelpersRouteRouteImport.update({ path: '/sql-helpers', getParentRoute: () => rootRouteImport, } as any) +const ServingRouteRoute = ServingRouteRouteImport.update({ + id: '/serving', + path: '/serving', + getParentRoute: () => rootRouteImport, +} as any) const ReconnectRouteRoute = ReconnectRouteRouteImport.update({ id: '/reconnect', path: '/reconnect', @@ -93,6 +99,7 @@ export interface FileRoutesByFullPath { '/genie': typeof GenieRouteRoute '/lakebase': typeof LakebaseRouteRoute '/reconnect': typeof ReconnectRouteRoute + '/serving': typeof ServingRouteRoute '/sql-helpers': typeof SqlHelpersRouteRoute '/telemetry': typeof TelemetryRouteRoute '/type-safety': typeof TypeSafetyRouteRoute @@ -107,6 +114,7 @@ export interface FileRoutesByTo { '/genie': typeof GenieRouteRoute '/lakebase': typeof LakebaseRouteRoute '/reconnect': typeof ReconnectRouteRoute + '/serving': typeof ServingRouteRoute '/sql-helpers': typeof SqlHelpersRouteRoute '/telemetry': typeof TelemetryRouteRoute '/type-safety': typeof TypeSafetyRouteRoute @@ -122,6 +130,7 @@ export interface FileRoutesById { '/genie': typeof GenieRouteRoute '/lakebase': typeof LakebaseRouteRoute '/reconnect': typeof ReconnectRouteRoute + '/serving': typeof ServingRouteRoute '/sql-helpers': typeof SqlHelpersRouteRoute '/telemetry': typeof TelemetryRouteRoute '/type-safety': typeof TypeSafetyRouteRoute @@ -138,6 +147,7 @@ export interface FileRouteTypes { | '/genie' | '/lakebase' | '/reconnect' + | '/serving' | '/sql-helpers' | '/telemetry' | '/type-safety' @@ -152,6 +162,7 @@ export interface FileRouteTypes { | '/genie' | '/lakebase' | '/reconnect' + | '/serving' | '/sql-helpers' | '/telemetry' | '/type-safety' @@ -166,6 +177,7 @@ export interface FileRouteTypes { | '/genie' | '/lakebase' | '/reconnect' + | '/serving' | '/sql-helpers' | '/telemetry' | '/type-safety' @@ -181,6 +193,7 @@ export interface RootRouteChildren { GenieRouteRoute: typeof GenieRouteRoute LakebaseRouteRoute: typeof LakebaseRouteRoute ReconnectRouteRoute: typeof ReconnectRouteRoute + ServingRouteRoute: typeof ServingRouteRoute SqlHelpersRouteRoute: typeof SqlHelpersRouteRoute TelemetryRouteRoute: typeof TelemetryRouteRoute TypeSafetyRouteRoute: typeof TypeSafetyRouteRoute @@ -209,6 +222,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof SqlHelpersRouteRouteImport parentRoute: typeof rootRouteImport } + '/serving': { + id: '/serving' + path: '/serving' + fullPath: '/serving' + preLoaderRoute: typeof ServingRouteRouteImport + parentRoute: typeof rootRouteImport + } '/reconnect': { id: '/reconnect' path: '/reconnect' @@ -285,6 +305,7 @@ const rootRouteChildren: RootRouteChildren = { GenieRouteRoute: GenieRouteRoute, LakebaseRouteRoute: LakebaseRouteRoute, ReconnectRouteRoute: ReconnectRouteRoute, + ServingRouteRoute: ServingRouteRoute, SqlHelpersRouteRoute: SqlHelpersRouteRoute, TelemetryRouteRoute: TelemetryRouteRoute, TypeSafetyRouteRoute: TypeSafetyRouteRoute, diff --git a/apps/dev-playground/client/src/routes/__root.tsx b/apps/dev-playground/client/src/routes/__root.tsx index 5cf74ce3..35a2282b 100644 --- a/apps/dev-playground/client/src/routes/__root.tsx +++ b/apps/dev-playground/client/src/routes/__root.tsx @@ -104,6 +104,14 @@ function RootComponent() { Files + + + diff --git a/apps/dev-playground/client/src/routes/index.tsx b/apps/dev-playground/client/src/routes/index.tsx index e331d93c..934b1467 100644 --- a/apps/dev-playground/client/src/routes/index.tsx +++ b/apps/dev-playground/client/src/routes/index.tsx @@ -218,6 +218,24 @@ function IndexRoute() { + + +
+

+ Model Serving +

+

+ Chat with a Databricks Model Serving endpoint using streaming + completions with real-time SSE responses. +

+ +
+
diff --git a/apps/dev-playground/client/src/routes/serving.route.tsx b/apps/dev-playground/client/src/routes/serving.route.tsx new file mode 100644 index 00000000..770d42f4 --- /dev/null +++ b/apps/dev-playground/client/src/routes/serving.route.tsx @@ -0,0 +1,148 @@ +import { useServingStream } from "@databricks/appkit-ui/react"; +import { createFileRoute } from "@tanstack/react-router"; +import { useEffect, useRef, useState } from "react"; + +export const Route = createFileRoute("/serving")({ + component: ServingRoute, +}); + +interface Message { + id: string; + role: "user" | "assistant"; + content: string; +} + +function extractContent(chunk: unknown): string { + return ( + (chunk as { choices?: { delta?: { content?: string } }[] })?.choices?.[0] + ?.delta?.content ?? "" + ); +} + +function ServingRoute() { + const [input, setInput] = useState(""); + const [messages, setMessages] = useState([]); + + const { stream, chunks, streaming, error, reset } = useServingStream({ + messages: [], + }); + + const streamingContent = chunks.map(extractContent).join(""); + + // Commit assistant message when streaming transitions from true → false + const prevStreamingRef = useRef(false); + useEffect(() => { + if (prevStreamingRef.current && !streaming && streamingContent) { + setMessages((prev) => [ + ...prev, + { + id: crypto.randomUUID(), + role: "assistant", + content: streamingContent, + }, + ]); + reset(); + } + prevStreamingRef.current = streaming; + }, [streaming, streamingContent, reset]); + + function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + if (!input.trim() || streaming) return; + + const userMessage: Message = { + id: crypto.randomUUID(), + role: "user", + content: input.trim(), + }; + + const fullMessages = [ + ...messages.map(({ role, content }) => ({ role, content })), + { role: "user" as const, content: userMessage.content }, + ]; + + setMessages((prev) => [...prev, userMessage]); + setInput(""); + reset(); + stream({ messages: fullMessages }); + } + + return ( +
+
+
+
+

+ Model Serving +

+

+ Chat with a Databricks Model Serving endpoint. Set{" "} + + DATABRICKS_SERVING_ENDPOINT + {" "} + to enable. +

+
+ +
+ {/* Messages area */} +
+ {messages.map((msg) => ( +
+
+

{msg.content}

+
+
+ ))} + + {/* Streaming response */} + {streaming && ( +
+
+

+ {streamingContent || "..."} +

+
+
+ )} + + {error && ( +
+ Error: {error} +
+ )} +
+ + {/* Input area */} +
+ setInput(e.target.value)} + placeholder="Send a message..." + className="flex-1 rounded-md border px-3 py-2 text-sm bg-background" + disabled={streaming} + /> + +
+
+
+
+
+ ); +} diff --git a/apps/dev-playground/client/vite.config.ts b/apps/dev-playground/client/vite.config.ts index f892c62f..5f37880b 100644 --- a/apps/dev-playground/client/vite.config.ts +++ b/apps/dev-playground/client/vite.config.ts @@ -1,4 +1,5 @@ import path from "node:path"; +import { appKitServingTypesPlugin } from "@databricks/appkit"; import { tanstackRouter } from "@tanstack/router-plugin/vite"; import react from "@vitejs/plugin-react"; import { defineConfig } from "vite"; @@ -11,6 +12,7 @@ export default defineConfig({ target: "react", autoCodeSplitting: process.env.NODE_ENV !== "development", }), + appKitServingTypesPlugin(), ], server: { hmr: { diff --git a/apps/dev-playground/server/index.ts b/apps/dev-playground/server/index.ts index a4b6a2c6..af05b11f 100644 --- a/apps/dev-playground/server/index.ts +++ b/apps/dev-playground/server/index.ts @@ -1,5 +1,12 @@ import "reflect-metadata"; -import { analytics, createApp, files, genie, server } from "@databricks/appkit"; +import { + analytics, + createApp, + files, + genie, + server, + serving, +} from "@databricks/appkit"; import { WorkspaceClient } from "@databricks/sdk-experimental"; import { lakebaseExamples } from "./lakebase-examples-plugin"; import { reconnect } from "./reconnect-plugin"; @@ -26,6 +33,7 @@ createApp({ }), lakebaseExamples(), files(), + serving(), ], ...(process.env.APPKIT_E2E_TEST && { client: createMockClient() }), }).then((appkit) => { diff --git a/docs/docs/plugins/serving.md b/docs/docs/plugins/serving.md new file mode 100644 index 00000000..4b2d7a54 --- /dev/null +++ b/docs/docs/plugins/serving.md @@ -0,0 +1,213 @@ +--- +sidebar_position: 7 +--- + +# Serving plugin + +Provides an authenticated proxy to [Databricks Model Serving](https://docs.databricks.com/aws/en/machine-learning/model-serving) endpoints, with invoke and streaming support. + +**Key features:** +- Named endpoint aliases for multiple serving endpoints +- Non-streaming (`invoke`) and SSE streaming (`stream`) invocation +- Automatic OpenAPI type generation for request/response schemas +- Request body filtering based on endpoint schema +- On-behalf-of (OBO) user execution + +## Basic usage + +```ts +import { createApp, server, serving } from "@databricks/appkit"; + +await createApp({ + plugins: [ + server(), + serving(), + ], +}); +``` + +With no configuration, the plugin reads `DATABRICKS_SERVING_ENDPOINT` from the environment and registers it under the `default` alias. + +## Configuration options + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `endpoints` | `Record` | `{ default: { env: "DATABRICKS_SERVING_ENDPOINT" } }` | Map of alias names to endpoint configs | +| `timeout` | `number` | `120000` | Request timeout in ms | + +### Endpoint aliases + +Endpoint aliases let you reference multiple serving endpoints by name: + +```ts +serving({ + endpoints: { + llm: { env: "DATABRICKS_SERVING_ENDPOINT" }, + classifier: { env: "DATABRICKS_SERVING_ENDPOINT_CLASSIFIER" }, + }, +}) +``` + +Each alias maps to an environment variable holding the actual endpoint name. If an endpoint serves multiple models, you can use `servedModel` to bypass traffic routing and target a specific model directly: + +```ts +serving({ + endpoints: { + llm: { env: "DATABRICKS_SERVING_ENDPOINT", servedModel: "llama-v2" }, + }, +}) +``` + +## Type generation + +The `appKitServingTypesPlugin()` Vite plugin generates TypeScript types from your serving endpoints' OpenAPI schemas. Add it to your `vite.config.ts`: + +```ts +import { appKitServingTypesPlugin } from "@databricks/appkit"; + +export default defineConfig({ + plugins: [ + appKitServingTypesPlugin(), + ], +}); +``` + +The plugin auto-discovers endpoint configuration from your server file (`server/index.ts` or `server/server.ts`) — no manual config passing needed. + +Generated types provide: +- **Alias autocomplete** in both backend (`AppKit.serving("alias")`) and frontend hooks (`useServingStream`, `useServingInvoke`) +- **Typed request/response/chunk** per endpoint based on OpenAPI schemas + +If an endpoint's OpenAPI schema is unavailable (not deployed, env var not set), the plugin generates generic fallback types. The endpoint is still usable — just without typed request/response. + +:::note +Endpoints that don't define a streaming response schema in their OpenAPI spec will have `chunk: unknown`. For these endpoints, use `useServingInvoke` instead of `useServingStream` — the `response` type will still be properly typed. +::: + +## Environment variables + +| Variable | Description | +|----------|-------------| +| `DATABRICKS_SERVING_ENDPOINT` | Default endpoint name (used when `endpoints` config is omitted) | + +When using named endpoints, define a custom environment variable per alias (e.g. `DATABRICKS_SERVING_ENDPOINT_CLASSIFIER`). + +## HTTP endpoints + +### Named mode (with `endpoints` config) + +- `POST /api/serving/:alias/invoke` — Non-streaming invocation +- `POST /api/serving/:alias/stream` — SSE streaming invocation + +### Default mode (no `endpoints` config) + +- `POST /api/serving/invoke` — Non-streaming invocation +- `POST /api/serving/stream` — SSE streaming invocation + +### Request format + +``` +POST /api/serving/:alias/invoke +Content-Type: application/json + +{ + "messages": [ + { "role": "user", "content": "Hello" } + ] +} +``` + +## Programmatic access + +The plugin exports `invoke` and `stream` methods for server-side use: + +```ts +const AppKit = await createApp({ + plugins: [ + server(), + serving({ + endpoints: { + llm: { env: "DATABRICKS_SERVING_ENDPOINT" }, + }, + }), + ], +}); + +// Non-streaming +const result = await AppKit.serving("llm").invoke({ + messages: [{ role: "user", content: "Hello" }], +}); + +// Streaming +for await (const chunk of AppKit.serving("llm").stream({ + messages: [{ role: "user", content: "Hello" }], +})) { + console.log(chunk); +} +``` + +## Frontend hooks + +The `@databricks/appkit-ui` package provides React hooks for serving endpoints: + +### useServingStream + +Streaming invocation via SSE: + +```tsx +import { useServingStream } from "@databricks/appkit-ui/react"; + +function ChatStream() { + const { stream, chunks, streaming, error, reset } = useServingStream( + { messages: [{ role: "user", content: "Hello" }] }, + { + alias: "llm", + onComplete: (finalChunks) => { + // Called with all accumulated chunks when the stream finishes + console.log("Stream done, got", finalChunks.length, "chunks"); + }, + }, + ); + + return ( + <> + + + {chunks.map((chunk, i) =>
{JSON.stringify(chunk)}
)} + {error &&

{error}

} + + ); +} +``` + +### useServingInvoke + +Non-streaming invocation. `invoke()` returns a promise with the response data (or `null` on error): + +```tsx +import { useServingInvoke } from "@databricks/appkit-ui/react"; + +function Classify() { + const { invoke, data, loading, error } = useServingInvoke( + { inputs: ["sample text"] }, + { alias: "classifier" }, + ); + + async function handleClick() { + const result = await invoke(); + if (result) { + console.log("Classification result:", result); + } + } + + return ( + <> + + {data &&
{JSON.stringify(data)}
} + {error &&

{error}

} + + ); +} +``` + +Both hooks accept `autoStart: true` to invoke automatically on mount. diff --git a/template/appkit.plugins.json b/template/appkit.plugins.json index cf60a8af..c21d8e80 100644 --- a/template/appkit.plugins.json +++ b/template/appkit.plugins.json @@ -149,6 +149,30 @@ "optional": [] }, "requiredByTemplate": true + }, + "serving": { + "name": "serving", + "displayName": "Model Serving Plugin", + "description": "Authenticated proxy to Databricks Model Serving endpoints", + "package": "@databricks/appkit", + "resources": { + "required": [ + { + "type": "serving_endpoint", + "alias": "Serving Endpoint", + "resourceKey": "serving-endpoint", + "description": "Model Serving endpoint for inference", + "permission": "CAN_QUERY", + "fields": { + "name": { + "env": "DATABRICKS_SERVING_ENDPOINT", + "description": "Serving endpoint name" + } + } + } + ], + "optional": [] + } } } } diff --git a/template/client/src/App.tsx b/template/client/src/App.tsx index fb4c28e6..a94bb5bc 100644 --- a/template/client/src/App.tsx +++ b/template/client/src/App.tsx @@ -17,6 +17,9 @@ import { GeniePage } from './pages/genie/GeniePage'; {{- if .plugins.files}} import { FilesPage } from './pages/files/FilesPage'; {{- end}} +{{- if .plugins.serving}} +import { ServingPage } from './pages/serving/ServingPage'; +{{- end}} const navLinkClass = ({ isActive }: { isActive: boolean }) => `px-3 py-1.5 rounded-md text-sm font-medium transition-colors ${ @@ -53,6 +56,11 @@ function Layout() { Files +{{- end}} +{{- if .plugins.serving}} + + Serving + {{- end}} @@ -80,6 +88,9 @@ const router = createBrowserRouter([ {{- end}} {{- if .plugins.files}} { path: '/files', element: }, +{{- end}} +{{- if .plugins.serving}} + { path: '/serving', element: }, {{- end}} ], }, diff --git a/template/client/src/pages/serving/ServingPage.tsx b/template/client/src/pages/serving/ServingPage.tsx new file mode 100644 index 00000000..b80934ba --- /dev/null +++ b/template/client/src/pages/serving/ServingPage.tsx @@ -0,0 +1,127 @@ +{{if .plugins.serving -}} +import { useServingInvoke } from '@databricks/appkit-ui/react'; +// For streaming endpoints (e.g. chat models), use useServingStream instead: +// import { useServingStream } from '@databricks/appkit-ui/react'; +import { useState } from 'react'; + +interface ChatChoice { + message?: { content?: string }; +} + +interface ChatResponse { + choices?: ChatChoice[]; +} + +function extractContent(data: unknown): string { + const resp = data as ChatResponse; + return resp?.choices?.[0]?.message?.content ?? JSON.stringify(data); +} + +interface Message { + id: string; + role: 'user' | 'assistant'; + content: string; +} + +export function ServingPage() { + const [input, setInput] = useState(''); + const [messages, setMessages] = useState([]); + + const { invoke, loading, error } = useServingInvoke({ messages: [] }); + // For streaming endpoints (e.g. chat models), use useServingStream instead: + // const { stream, chunks, streaming, error, reset } = useServingStream({ messages: [] }); + // Then accumulate chunks: chunks.map(c => c?.choices?.[0]?.delta?.content ?? '').join('') + + function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + if (!input.trim() || loading) return; + + const userMessage: Message = { + id: crypto.randomUUID(), + role: 'user', + content: input.trim(), + }; + + const fullMessages = [ + ...messages.map(({ role, content }) => ({ role, content })), + { role: 'user' as const, content: userMessage.content }, + ]; + + setMessages((prev) => [...prev, userMessage]); + setInput(''); + + void invoke({ messages: fullMessages }).then((result) => { + if (result) { + setMessages((prev) => [ + ...prev, + { id: crypto.randomUUID(), role: 'assistant', content: extractContent(result) }, + ]); + } + }); + } + + return ( +
+
+

Model Serving

+

+ Chat with a Databricks Model Serving endpoint. +

+
+ +
+
+ {messages.map((msg) => ( +
+
+

{msg.content}

+
+
+ ))} + + {loading && ( +
+
+

...

+
+
+ )} + + {error && ( +
+ Error: {error} +
+ )} +
+ +
+ setInput(e.target.value)} + placeholder="Send a message..." + className="flex-1 rounded-md border px-3 py-2 text-sm bg-background" + disabled={loading} + /> + +
+
+
+ ); +} +{{- end}} diff --git a/template/client/vite.config.ts b/template/client/vite.config.ts index b49d4055..12c1d864 100644 --- a/template/client/vite.config.ts +++ b/template/client/vite.config.ts @@ -2,11 +2,20 @@ import { defineConfig } from 'vite'; import react from '@vitejs/plugin-react'; import tailwindcss from '@tailwindcss/vite'; import path from 'node:path'; +{{- if .plugins.serving}} +import { appKitServingTypesPlugin } from '@databricks/appkit'; +{{- end}} // https://vite.dev/config/ export default defineConfig({ root: __dirname, - plugins: [react(), tailwindcss()], + plugins: [ + react(), + tailwindcss(), +{{- if .plugins.serving}} + appKitServingTypesPlugin(), +{{- end}} + ], server: { middlewareMode: true, }, diff --git a/template/databricks.yml.tmpl b/template/databricks.yml.tmpl index accf7709..77997d31 100644 --- a/template/databricks.yml.tmpl +++ b/template/databricks.yml.tmpl @@ -13,7 +13,7 @@ resources: description: "{{.appDescription}}" source_code_path: ./ -{{- if or .plugins.genie .plugins.files}} +{{- if or .plugins.genie .plugins.files .plugins.serving}} user_api_scopes: {{- if .plugins.genie}} - dashboards.genie @@ -21,8 +21,11 @@ resources: {{- if .plugins.files}} - files.files {{- end}} +{{- if .plugins.serving}} + - serving.serving-endpoints +{{- end}} {{- else}} - # Uncomment to enable on behalf of user API scopes. Available scopes: sql, dashboards.genie, files.files + # Uncomment to enable on behalf of user API scopes. Available scopes: sql, dashboards.genie, files.files, serving.serving-endpoints # user_api_scopes: # - sql {{- end}} diff --git a/tools/generate-app-templates.ts b/tools/generate-app-templates.ts index 4b029121..1eff9357 100644 --- a/tools/generate-app-templates.ts +++ b/tools/generate-app-templates.ts @@ -55,21 +55,23 @@ const FEATURE_DEPENDENCIES: Record = { files: "Volume", genie: "Genie Space", lakebase: "Database", + serving: "Serving Endpoint", }; const APP_TEMPLATES: AppTemplate[] = [ { name: "appkit-all-in-one", - features: ["analytics", "files", "genie", "lakebase"], + features: ["analytics", "files", "genie", "lakebase", "serving"], set: { "analytics.sql-warehouse.id": "placeholder", "files.files.path": "placeholder", "genie.genie-space.id": "placeholder", "lakebase.postgres.branch": "placeholder", "lakebase.postgres.database": "placeholder", + "serving.serving-endpoint.name": "placeholder", }, description: - "Full-stack Node.js app with SQL analytics dashboards, file browser, Genie AI conversations, and Lakebase Autoscaling (Postgres) CRUD", + "Full-stack Node.js app with SQL analytics dashboards, file browser, Genie AI conversations, Lakebase Autoscaling (Postgres) CRUD, and Model Serving", }, { name: "appkit-analytics", @@ -96,6 +98,15 @@ const APP_TEMPLATES: AppTemplate[] = [ }, description: "Node.js app with file browser for Databricks Volumes", }, + { + name: "appkit-serving", + features: ["serving"], + set: { + "serving.serving-endpoint.name": "placeholder", + }, + description: + "Node.js app with Databricks Model Serving endpoint integration", + }, { name: "appkit-lakebase", features: ["lakebase"],