From 2de07a5f22749512e96f90fdd2a6bb9dbc5bd5f7 Mon Sep 17 00:00:00 2001
From: David Sanchez <64162682+dsfaccini@users.noreply.github.com>
Date: Wed, 19 Aug 2026 13:33:39 -0500
Subject: [PATCH] feat: support runtime path configuration
---
CLAUDE.md | 13 +++--
README.md | 17 +++++++
playwright.offline.config.ts | 11 ++--
src/Chat.tsx | 3 +-
src/lib/base-path.ts | 5 +-
src/lib/config.ts | 52 ++++++++++++++++++-
src/vite-env.d.ts | 4 ++
tests/e2e/conversation.ts | 8 ++-
tests/e2e/offline/offline-artifact.spec.ts | 59 ++++++++++++++++++++--
tests/headless/config.test.ts | 41 ++++++++++++++-
vite.config.ts | 13 +++--
11 files changed, 206 insertions(+), 20 deletions(-)
diff --git a/CLAUDE.md b/CLAUDE.md
index e5e14da4..873234c0 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -84,7 +84,7 @@ The shell is composed as sidebar → `AppHeader` → conversation → `ChatCompo
**Model & Tool Selection:**
-- Dynamic model/tool configuration fetched from `/api/configure`
+- Dynamic model/tool configuration fetched from the configured API path (`/api/configure` by default)
- Models and available builtin tools configured per-model
- Tools toggled via checkboxes in prompt toolbar
@@ -103,13 +103,19 @@ The shell is composed as sidebar → `AppHeader` → conversation → `ChatCompo
### Backend Integration
-**Endpoints:**
+**Default endpoints:**
- `GET /api/configure`: Returns available models and builtin tools (camelCase)
- `POST /api/chat`: Handles chat messages via `VercelAIAdapter`
- Accepts `model` and `builtinTools` in request body extra data
- Streams responses using SSE
+Set `window.PYDANTIC_AI_CHAT_CONFIG` before the UI module executes to override paths at runtime.
+
+- Use `basePath` to control conversation navigation.
+- Use `apiPath` as the complete same-origin directory containing `configure` and `chat`.
+- Keep `apiPath` independent of `basePath`; its default is `/api/`.
+
**Token usage:**
The UI shows per-reply and per-conversation token counts, read from `UIMessage.metadata.usage` on assistant messages:
@@ -150,7 +156,8 @@ Two normalizations are part of vendoring itself, not local modifications: files
- **TypeScript paths**: `@/*` maps to `./src/*`
- **Vite base URL**: CDN path for production (`jsdelivr.net/npm/@pydantic/pydantic-ai-chat/dist/`)
-- **Dev proxy**: `/api` proxied to `localhost:8000`
+- **Runtime paths**: `window.PYDANTIC_AI_CHAT_CONFIG` supplies independent `basePath` and `apiPath` values
+- **Dev proxy**: `/api` proxied to `localhost:38001`
- **Package**: Published as `@pydantic/pydantic-ai-chat` (public npm package)
## Tech Stack
diff --git a/README.md b/README.md
index c7e27c91..5133167b 100644
--- a/README.md
+++ b/README.md
@@ -13,6 +13,23 @@ Built with [Vercel AI SDK](https://sdk.vercel.ai/) and designed to work with Pyd
- Dark/light theme support
- Mobile-responsive sidebar
+## Hosting below a path prefix
+
+Set the startup configuration before the UI's module script executes when the app is served below a same-origin path prefix:
+
+```html
+
+```
+
+`basePath` controls conversation URLs and browser navigation. `apiPath` is the complete directory containing the `configure` and `chat` endpoints; it is independent of `basePath`, so a UI below `/chat/` may still use `/api/`. Both values accept same-origin paths and are normalized with a trailing slash.
+
+Without startup configuration, `basePath` uses the normalized Vite base (offline and CDN builds use `/`) and `apiPath` uses `/api/`. This configuration also works when a server injects the script into the single-file offline artifact at request time; the artifact does not need to be rebuilt.
+
## Development
```sh
diff --git a/playwright.offline.config.ts b/playwright.offline.config.ts
index b043fc1c..de982ef6 100644
--- a/playwright.offline.config.ts
+++ b/playwright.offline.config.ts
@@ -18,6 +18,8 @@ process.env.no_proxy = process.env.NO_PROXY
const TEST_SERVER_PORT = 38788
const TEST_UI_PORT = 54322
+const TEST_BASE_PATH = '/demo/'
+const TEST_API_PATH = '/demo/api/'
export default defineConfig({
testDir: 'tests/e2e/offline',
@@ -36,7 +38,7 @@ export default defineConfig({
},
],
use: {
- baseURL: `http://127.0.0.1:${TEST_UI_PORT}`,
+ baseURL: `http://127.0.0.1:${TEST_UI_PORT}${TEST_BASE_PATH}`,
trace: 'retain-on-failure',
},
webServer: [
@@ -52,10 +54,11 @@ export default defineConfig({
// command into the readiness wait.
command:
`BACKEND_PORT=${TEST_SERVER_PORT} ` +
+ `API_PROXY_PATH=${TEST_API_PATH.slice(0, -1)} ` +
`pnpm exec vite preview --outDir offline --port ${TEST_UI_PORT} --host 127.0.0.1 --strictPort`,
- // Probe the proxied API rather than `/`: the readiness fetch would otherwise pull the
- // whole 15MB artifact, and this also confirms the /api proxy is wired up.
- url: `http://127.0.0.1:${TEST_UI_PORT}/api/configure`,
+ // Probe the prefixed API rather than the UI: the readiness fetch would otherwise pull
+ // the whole 15MB artifact, and this also confirms the rewrite to /api is wired up.
+ url: `http://127.0.0.1:${TEST_UI_PORT}${TEST_API_PATH}configure`,
reuseExistingServer: !process.env.CI,
timeout: 30_000,
},
diff --git a/src/Chat.tsx b/src/Chat.tsx
index 1e8d8513..a0de7d67 100644
--- a/src/Chat.tsx
+++ b/src/Chat.tsx
@@ -29,7 +29,7 @@ import { Part } from './Part'
import type { ThinkingEffort } from '@/lib/generated/thinking-effort.gen'
import type { ConversationEntry } from './types'
import { readEffort, writeEffort } from '@/lib/effort'
-import { fetchConfig } from '@/lib/config'
+import { fetchConfig, startupConfig } from '@/lib/config'
import { resolveSelectedModel } from '@/lib/models'
import { toolNameOfPart } from '@/lib/tool-filters'
import { COMPLETE_TOOL_STATES, groupParts, type PartRun } from '@/lib/tool-grouping'
@@ -61,6 +61,7 @@ const ChatInner = () => {
const [transport] = useState(
() =>
new DefaultChatTransport({
+ api: `${startupConfig.apiPath}chat`,
body: () => ({ model: modelRef.current, builtinTools: enabledToolsRef.current, effort: effortRef.current }),
}),
)
diff --git a/src/lib/base-path.ts b/src/lib/base-path.ts
index eb80660b..cff91fc6 100644
--- a/src/lib/base-path.ts
+++ b/src/lib/base-path.ts
@@ -1,5 +1,6 @@
-const RAW = import.meta.env.BASE_URL
-const BASE = RAW.startsWith('http') ? '/' : RAW.endsWith('/') ? RAW : RAW + '/'
+import { startupConfig } from '@/lib/config'
+
+const BASE = startupConfig.basePath
export function stripBasePath(pathname: string): string {
if (BASE === '/') return pathname
diff --git a/src/lib/config.ts b/src/lib/config.ts
index f0fe3a09..ccd3ba26 100644
--- a/src/lib/config.ts
+++ b/src/lib/config.ts
@@ -1,11 +1,61 @@
import { isRecord } from '@/lib/is-record'
import type { BuiltinTool, ModelConfig } from '@/types'
+export interface StartupConfig {
+ basePath?: string
+ apiPath?: string
+}
+
+export interface ResolvedStartupConfig {
+ readonly basePath: string
+ readonly apiPath: string
+}
+
export interface RemoteConfig {
models: ModelConfig[]
builtinTools: BuiltinTool[]
}
+const PATH_ORIGIN = 'https://pydantic-ai-chat.invalid'
+
+/** Normalize a same-origin directory path for safe suffix concatenation. */
+export function normalizeDirectoryPath(path: string, name: string): string {
+ const url = new URL(path, PATH_ORIGIN)
+ if (url.origin !== PATH_ORIGIN || url.search || url.hash) {
+ throw new TypeError(`${name} must be a same-origin path without a query or fragment`)
+ }
+ return url.pathname.endsWith('/') ? url.pathname : `${url.pathname}/`
+}
+
+function defaultBasePath(viteBase: string): string {
+ const url = new URL(viteBase || '/', PATH_ORIGIN)
+ return url.origin === PATH_ORIGIN ? normalizeDirectoryPath(url.pathname, 'Vite base') : '/'
+}
+
+function configuredPath(config: unknown, key: keyof StartupConfig): string | undefined {
+ if (config === undefined) return undefined
+ if (!isRecord(config)) throw new TypeError('PYDANTIC_AI_CHAT_CONFIG must be an object')
+ const value = config[key]
+ if (value !== undefined && typeof value !== 'string') {
+ throw new TypeError(`PYDANTIC_AI_CHAT_CONFIG.${key} must be a string`)
+ }
+ return value
+}
+
+export function resolveStartupConfig(
+ config: unknown = typeof window === 'undefined' ? undefined : window.PYDANTIC_AI_CHAT_CONFIG,
+ viteBase: string = import.meta.env.BASE_URL,
+): ResolvedStartupConfig {
+ const basePath = configuredPath(config, 'basePath')
+ const apiPath = configuredPath(config, 'apiPath')
+ return Object.freeze({
+ basePath: basePath === undefined ? defaultBasePath(viteBase) : normalizeDirectoryPath(basePath, 'basePath'),
+ apiPath: apiPath === undefined ? '/api/' : normalizeDirectoryPath(apiPath, 'apiPath'),
+ })
+}
+
+export const startupConfig = resolveStartupConfig()
+
function isModelConfig(value: unknown): value is ModelConfig {
if (!isRecord(value)) return false
return (
@@ -50,7 +100,7 @@ function isRemoteConfig(value: unknown): value is RemoteConfig {
* the chat down instead.
*/
export async function fetchConfig(): Promise {
- const res = await fetch('/api/configure')
+ const res = await fetch(`${startupConfig.apiPath}configure`)
if (!res.ok) {
throw new Error(`Configuration request failed with ${String(res.status)}`)
}
diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts
index 11f02fe2..a33d22be 100644
--- a/src/vite-env.d.ts
+++ b/src/vite-env.d.ts
@@ -1 +1,5 @@
///
+
+interface Window {
+ PYDANTIC_AI_CHAT_CONFIG?: import('./lib/config').StartupConfig
+}
diff --git a/tests/e2e/conversation.ts b/tests/e2e/conversation.ts
index 1a366049..515b2cef 100644
--- a/tests/e2e/conversation.ts
+++ b/tests/e2e/conversation.ts
@@ -34,7 +34,12 @@ export async function sendMessage(page: Page, model: string, message: string) {
await input.press('Enter')
}
-export async function waitForPersisted(page: Page, minMessages = 2, timeoutMs = 10_000) {
+export async function waitForPersisted(
+ page: Page,
+ minMessages = 2,
+ timeoutMs = 10_000,
+ conversationId = new URL(page.url()).pathname,
+) {
// Messages are persisted to IndexedDB (db `chat-storage`, store `messages`)
// throttled at 500ms (see src/lib/chat-db.ts). Poll inside the page until the
// record has at least `minMessages` entries AND the last (assistant) message
@@ -46,7 +51,6 @@ export async function waitForPersisted(page: Page, minMessages = 2, timeoutMs =
// Implemented as a single page.evaluate with a JS poll loop rather than
// page.waitForFunction(): the latter does not reliably re-evaluate a
// Promise-returning callback that opens IDB on each iteration.
- const conversationId = new URL(page.url()).pathname
await page.evaluate(
async ({ id, min, timeout }) => {
/* global indexedDB */
diff --git a/tests/e2e/offline/offline-artifact.spec.ts b/tests/e2e/offline/offline-artifact.spec.ts
index 4007cf1d..03f99757 100644
--- a/tests/e2e/offline/offline-artifact.spec.ts
+++ b/tests/e2e/offline/offline-artifact.spec.ts
@@ -1,6 +1,9 @@
import { test, expect } from '@playwright/test'
import type { Page } from '@playwright/test'
-import { sendMessage } from '../conversation'
+import { sendMessage, waitForPersisted } from '../conversation'
+
+const BASE_PATH = '/demo/'
+const API_PATH = '/demo/api/'
// The bug (pydantic-ai#5318): the CDN build bakes an absolute jsdelivr base into
// index.html *and* into the runtime chunk loader, so a self-hosted copy silently reaches
@@ -24,12 +27,32 @@ async function blockExternalRequests(page: Page): Promise {
return attempted
}
+async function configurePage(page: Page, apiPath: string): Promise {
+ await page.addInitScript(
+ ({ basePath, apiPath }) => {
+ window.PYDANTIC_AI_CHAT_CONFIG = { basePath, apiPath }
+ },
+ { basePath: BASE_PATH, apiPath },
+ )
+}
+
test.describe('offline artifact', () => {
- test('renders code blocks and math with no external requests', async ({ page }) => {
+ test('works below a configured prefix with no external requests', async ({ page }) => {
+ await configurePage(page, API_PATH)
const attempted = await blockExternalRequests(page)
+ const configureRequest = page.waitForRequest(
+ (request) => new URL(request.url()).pathname === `${API_PATH}configure`,
+ )
- await page.goto('/')
+ await page.goto(BASE_PATH)
+ await configureRequest
+
+ const chatRequest = page.waitForRequest((request) => new URL(request.url()).pathname === `${API_PATH}chat`)
await sendMessage(page, 'markdown', 'Show me markdown')
+ await chatRequest
+
+ const conversationPath = new URL(page.url()).pathname
+ expect(conversationPath).toMatch(/^\/demo\/[\w-]+$/)
// A fenced code block resolves a shiki language grammar through a dynamic import. In
// the CDN build that import is fetched from jsdelivr at runtime; here it must already
@@ -41,7 +64,37 @@ test.describe('offline artifact', () => {
// markdown would still read `$$`.
await expect(page.getByText('E=mc2').first()).toBeVisible()
+ const conversationId = conversationPath.slice('/demo'.length)
+ await waitForPersisted(page, 2, 10_000, conversationId)
+
+ const conversationLink = page.getByRole('link', { name: /Show me markdown/ })
+ await expect(conversationLink).toHaveAttribute('href', conversationPath)
+ await page.getByRole('link', { name: 'New conversation' }).click()
+ await expect(page).toHaveURL(new RegExp(`${BASE_PATH}$`))
+ await conversationLink.click()
+ await expect(page).toHaveURL(new RegExp(`${conversationPath}$`))
+
+ await page.reload()
+ await expect(page.getByText('def greet():')).toBeVisible()
+
// The real assertion for the fonts: anything still living on the CDN shows up here.
expect(attempted).toEqual([])
})
+
+ test('keeps the API directory independent from the navigation prefix', async ({ page }) => {
+ await configurePage(page, '/api/')
+ const attempted = await blockExternalRequests(page)
+ const configureRequest = page.waitForRequest((request) => new URL(request.url()).pathname === '/api/configure')
+
+ await page.goto(BASE_PATH)
+ await configureRequest
+
+ const chatRequest = page.waitForRequest((request) => new URL(request.url()).pathname === '/api/chat')
+ await sendMessage(page, 'text', 'Hello')
+ await chatRequest
+
+ await expect(page).toHaveURL(/^http:\/\/127\.0\.0\.1:\d+\/demo\/[\w-]+$/)
+ await expect(page.getByText('Hello from the test server')).toBeVisible()
+ expect(attempted).toEqual([])
+ })
})
diff --git a/tests/headless/config.test.ts b/tests/headless/config.test.ts
index 1986f7e4..dded6c18 100644
--- a/tests/headless/config.test.ts
+++ b/tests/headless/config.test.ts
@@ -1,6 +1,6 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
-import { fetchConfig } from '../../src/lib/config'
+import { fetchConfig, normalizeDirectoryPath, resolveStartupConfig } from '../../src/lib/config'
const respond = (body: unknown, init: { status?: number } = {}) => {
const status = init.status ?? 200
@@ -20,6 +20,7 @@ describe('fetchConfig', () => {
respond(config)
await expect(fetchConfig()).resolves.toEqual(config)
+ expect(fetch).toHaveBeenCalledWith('/api/configure')
})
it('rejects an error response instead of storing it as configuration', async () => {
@@ -51,3 +52,41 @@ describe('fetchConfig', () => {
await expect(fetchConfig()).rejects.toThrow(/models and builtin tools/)
})
})
+
+describe('startup configuration', () => {
+ it('normalizes configured directories independently', () => {
+ expect(resolveStartupConfig({ basePath: '/demo', apiPath: 'services/chat' }, '/ignored/')).toEqual({
+ basePath: '/demo/',
+ apiPath: '/services/chat/',
+ })
+ expect(resolveStartupConfig({ basePath: '/demo' }, '/build/')).toEqual({
+ basePath: '/demo/',
+ apiPath: '/api/',
+ })
+ expect(resolveStartupConfig({ apiPath: '/demo/api' }, '/build/')).toEqual({
+ basePath: '/build/',
+ apiPath: '/demo/api/',
+ })
+ })
+
+ it('defaults to the normalized Vite base and root API directory', () => {
+ expect(resolveStartupConfig(undefined, '/docs')).toEqual({ basePath: '/docs/', apiPath: '/api/' })
+ expect(resolveStartupConfig(undefined, './')).toEqual({ basePath: '/', apiPath: '/api/' })
+ expect(resolveStartupConfig(undefined, 'https://cdn.example.com/package/')).toEqual({
+ basePath: '/',
+ apiPath: '/api/',
+ })
+ })
+
+ it('rejects origins, query strings, and fragments', () => {
+ expect(() => normalizeDirectoryPath('https://example.com/demo/', 'basePath')).toThrow(/same-origin path/)
+ expect(() => normalizeDirectoryPath('//example.com/demo/', 'basePath')).toThrow(/same-origin path/)
+ expect(() => normalizeDirectoryPath('/demo/?tenant=one', 'basePath')).toThrow(/query or fragment/)
+ expect(() => normalizeDirectoryPath('/demo/#chat', 'basePath')).toThrow(/query or fragment/)
+ })
+
+ it('rejects malformed startup configuration', () => {
+ expect(() => resolveStartupConfig('demo', '/')).toThrow(/must be an object/)
+ expect(() => resolveStartupConfig({ apiPath: 7 }, '/')).toThrow(/apiPath must be a string/)
+ })
+})
diff --git a/vite.config.ts b/vite.config.ts
index b6df3b25..40188b51 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -10,12 +10,19 @@ import tsconfigPaths from 'vite-tsconfig-paths'
// 8000 is quite common for backend, avoid the clash
const BACKEND_DEV_SERVER_PORT = process.env.BACKEND_PORT ?? 38001
+const API_PROXY_PATH = process.env.API_PROXY_PATH ?? '/api'
-const API_PROXY = {
- '/api': {
+function apiProxy(pathPrefix: string) {
+ return {
target: `http://localhost:${BACKEND_DEV_SERVER_PORT}/`,
changeOrigin: true,
- },
+ rewrite: (path: string) => `/api${path.slice(pathPrefix.length)}`,
+ }
+}
+
+const API_PROXY = {
+ '/api': apiProxy('/api'),
+ ...(API_PROXY_PATH === '/api' ? {} : { [API_PROXY_PATH]: apiProxy(API_PROXY_PATH) }),
}
const FAVICON_MIME_TYPES: Record = {