From 1af06f7f83bb4eda08feeb49cc70cdd51efa380d Mon Sep 17 00:00:00 2001 From: pdparchitect Date: Thu, 10 Sep 2026 23:08:42 +0000 Subject: [PATCH] chore: update version to 0.4.0 and add release date to changelog (+3 more) - chore: update version to 0.4.0 and add release date to changelog - Refactor image generation and editing API - fix: remove unnecessary separator in contributing links in README - feat: update references from ChatBotKit Studio to CBK Studio in documentation --- CHANGELOG.md | 43 ++- README.md | 4 +- docker/distro/community/compose.yml | 2 +- docker/distro/studio/compose.yml | 2 +- docs/deployment.md | 4 +- package.json | 2 +- .../(project)/[projectId]/components.jsx | 191 +++++----- .../f2a9c7d4/(project)/[projectId]/server.ts | 327 +----------------- platform/app/apps/(adhoc)/f2a9c7d4/README.md | 34 +- platform/app/apps/(adhoc)/f2a9c7d4/api.ts | 306 ++++++++++++++++ .../app/apps/(adhoc)/f2a9c7d4/api.utest.js | 290 ++++++++++++++++ .../apps/(adhoc)/f2a9c7d4/api/assets/route.ts | 7 + .../apps/(adhoc)/f2a9c7d4/api/edit/route.ts | 7 + .../(adhoc)/f2a9c7d4/api/generate/route.ts | 7 + .../apps/(adhoc)/f2a9c7d4/api/save/route.ts | 7 + .../apps/(adhoc)/f2a9c7d4/api/upload/route.ts | 7 + platform/app/apps/(adhoc)/f2a9c7d4/space.ts | 28 ++ platform/components/DynamicIcon.jsx | 12 +- platform/components/DynamicIcon.utest.jsx | 57 ++- platform/components/Link.jsx | 9 +- platform/components/Link.utest.jsx | 25 ++ platform/config/models.ts | 84 ++--- platform/lib/app.route.ts | 57 ++- platform/lib/app.route.utest.js | 110 +++++- platform/lib/auth.adapter.ts | 19 +- platform/lib/auth.adapter.utest.js | 38 ++ platform/lib/model.provider.vercel.utest.js | 195 ++++++++--- platform/lib/session.get.js | 19 +- platform/lib/session.get.utest.js | 21 ++ platform/next.config.d/hmr.config.js | 16 + platform/next.config.d/hmr.config.utest.js | 9 + 31 files changed, 1410 insertions(+), 529 deletions(-) create mode 100644 platform/app/apps/(adhoc)/f2a9c7d4/api.ts create mode 100644 platform/app/apps/(adhoc)/f2a9c7d4/api.utest.js create mode 100644 platform/app/apps/(adhoc)/f2a9c7d4/api/assets/route.ts create mode 100644 platform/app/apps/(adhoc)/f2a9c7d4/api/edit/route.ts create mode 100644 platform/app/apps/(adhoc)/f2a9c7d4/api/generate/route.ts create mode 100644 platform/app/apps/(adhoc)/f2a9c7d4/api/save/route.ts create mode 100644 platform/app/apps/(adhoc)/f2a9c7d4/api/upload/route.ts create mode 100644 platform/next.config.d/hmr.config.js create mode 100644 platform/next.config.d/hmr.config.utest.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 280acd2..1c35d10 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,11 +5,48 @@ here. The release version is defined in the workspace root `package.json`. ## [Unreleased] +## [0.4.0] - 2026-09-10 + +### Changed + +- Media Graph now generates, transforms, uploads and saves through route + handlers instead of server actions, so several nodes can generate at the + same time. Server actions are queued one at a time by the browser, which + made every generation wait for the previous one. The `appContactRouteHandler` + helper is the route counterpart of `appContactActionHandler`. + ### Fixed -- Bill DeepSeek V4.1 Flash through Vercel AI Gateway at the gateway's peak - rate and full 1,048,576-token context, matching the gateway catalogue rather - than DeepSeek's off-peak list price. +- Resolve cookie sessions in App Router route handlers. `getSession` handed + next-auth the Web `Request`, which carries neither the `cookies` map nor + the plain `headers` object next-auth reads, so every cookie-authenticated + route handler answered 401. Those requests now resolve through + `next/headers`, the same way server actions already did. +- Keep the dev server's Server Components HMR cache off. On a refresh after + a code change Next replayed every fetch of the render, keyed by URL and + body, and `cache: 'no-store'` does not opt a call out. The PlanetScale + driver's `BEGIN` request has the same body every time, so the driver was + handed the session of an already committed transaction and the following + statement failed with `transaction ... ended (transaction committed)`. + Development only; production never had the cache. +- Stop a failed session expiry bump from signing the user out of a request. + With database sessions NextAuth refreshes the expiry once its update window + has passed and treated any failure of that update as a lost session, so App + Router pages bounced to sign-in and on to the overview. The adapter now + reports the failure and keeps the session, which was already read with a + valid expiry. +- Stop the sidebar logo from reporting a hydration mismatch in dark mode. + The icon picked its dark variant from the theme on the first client render + while the server had rendered the light one, so React flagged the filter + style. The theme is now applied only after hydration. +- Stop the App Router build of `next/link` warning about the `locale` prop. + The shared `Link` wrapper forwarded the default locale on every link; it + now forwards a locale only when a caller sets one. +- Price multi-backend Vercel AI Gateway models at the most expensive backend + the gateway can route to. The gateway bills each request at the rate of the + backend it picks, so DeepSeek V4 Flash, DeepSeek V4 Pro, DeepSeek V4.1 + Flash, Gemma 4 31B, GLM-5.2, GLM-5.3 Flash, MiMo V2.5, MiMo V2.5 Pro and + MiniMax M3 no longer bill below what the gateway can charge. ## [0.3.3] - 2026-09-10 diff --git a/README.md b/README.md index 96da97d..4d22a9f 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ Try it · Documentation · Architecture · - Contributing · + Contributing

@@ -75,7 +75,7 @@ docker compose -f oci://ghcr.io/chatbotkit/platform-community:latest logs platfo See [Deployment](./docs/deployment.md) for details. On an Apple silicon Mac you can skip Docker altogether: -[ChatBotKit Studio](https://github.com/chatbotkit/studio) is a native macOS +[CBK Studio](https://github.com/chatbotkit/studio) is a native macOS app that runs this same platform in an app-private VM, with model providers, logs and updates managed from the app. diff --git a/docker/distro/community/compose.yml b/docker/distro/community/compose.yml index fad788c..73e3d62 100644 --- a/docker/distro/community/compose.yml +++ b/docker/distro/community/compose.yml @@ -23,7 +23,7 @@ # (and with it the app shells, spaces and portals), RELAY_PORT the realtime # relay and STORAGE_PORT the object store, and every address a browser or a # host process dials is declared once in the `x-cbk` manifest below. A -# launcher such as ChatBotKit Studio reads the resolved manifest back with +# launcher such as CBK Studio reads the resolved manifest back with # `docker compose config --format json` rather than assuming port 3000. # # One folder per package flavor lives under docker/distro/. Each publishes as diff --git a/docker/distro/studio/compose.yml b/docker/distro/studio/compose.yml index af82059..953e42e 100644 --- a/docker/distro/studio/compose.yml +++ b/docker/distro/studio/compose.yml @@ -23,7 +23,7 @@ # (and with it the app shells, spaces and portals), RELAY_PORT the realtime # relay and STORAGE_PORT the object store, and every address a browser or a # host process dials is declared once in the `x-cbk` manifest below. A -# launcher such as ChatBotKit Studio reads the resolved manifest back with +# launcher such as CBK Studio reads the resolved manifest back with # `docker compose config --format json` rather than assuming a port. # # Studio publishes on 31000, 31001 and 31900 rather than Community's 3000 diff --git a/docs/deployment.md b/docs/deployment.md index 5b817c8..d08e857 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -189,7 +189,7 @@ PLATFORM_HOST=studio.localhost \ } ``` -A launcher such as ChatBotKit Studio therefore never assumes a port: it +A launcher such as CBK Studio therefore never assumes a port: it keeps the flavor's defaults or sets `PLATFORM_PORT`, `RELAY_PORT` and `STORAGE_PORT` to free ones (and `PLATFORM_HOST` or any endpoint's variable to rename a host), reads the resolved manifest back and forwards, opens and trusts exactly the addresses @@ -233,7 +233,7 @@ its workspaces kept under `/data/sandbox` in the same volume. Studio starts as a copy of Community, with the same Docker build targets, module defaults and services. It is the flavor embedded by -[ChatBotKit Studio](https://github.com/chatbotkit/studio), the native macOS +[CBK Studio](https://github.com/chatbotkit/studio), the native macOS app that runs the platform in an app-private VM without a Docker install, and can also be run directly with Compose. Its separate Compose file lives at [docker/distro/studio/compose.yml](../docker/distro/studio/compose.yml), and the diff --git a/package.json b/package.json index 66cf6f5..5675b3b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "platform", - "version": "0.3.3", + "version": "0.4.0", "private": true, "license": "Apache-2.0", "packageManager": "pnpm@11.24.0", diff --git a/platform/app/apps/(adhoc)/f2a9c7d4/(project)/[projectId]/components.jsx b/platform/app/apps/(adhoc)/f2a9c7d4/(project)/[projectId]/components.jsx index 75751b7..1d90e1e 100644 --- a/platform/app/apps/(adhoc)/f2a9c7d4/(project)/[projectId]/components.jsx +++ b/platform/app/apps/(adhoc)/f2a9c7d4/(project)/[projectId]/components.jsx @@ -23,8 +23,6 @@ import { LuX, } from 'react-icons/lu' -import { errorToErrorResponse } from '@/lib/error' -import { throwUnprocessableEntity } from '@/lib/response' import { saveUrl } from '@/lib/save' import toast from '@/lib/toast' @@ -33,6 +31,7 @@ import { AppNavExtra } from '@/layouts/App' import { useConfirmYesNo } from '@/components/Confirm' import ImageModelSelect from '@/components/ImageModelSelect' +import useFetch from '@/hooks/useFetch' import useHistory from '@/hooks/useHistory' import usePreventLeave from '@/hooks/usePreventLeave' import useRouter from '@/hooks/useRouter' @@ -40,13 +39,6 @@ import useTheme from '@/hooks/useTheme' import { APP_NAME } from '../../const' import { DEFAULT_MODEL, DEFAULT_SIZE, SIZES } from '../../lib' -import { - createAssetUpload, - editImageNode, - generateImage, - getAssetUrls, - saveProject, -} from './server' import { Background, @@ -89,12 +81,52 @@ function NavIconButton({ ) } +const API_BASE = `/apps/${APP_NAME}/api` + +/** + * Returns a caller for the editor route handlers (see `../../api`). Each call + * is an independent request, so several nodes can generate at the same time - + * server actions would have been queued one after another by the client. + */ +function useApi() { + const { fetch } = useFetch({ trackLoading: false, trackStreaming: false }) + + return useCallback( + async (op, body, options = {}) => { + const { data, error } = await fetch(`${API_BASE}/${op}`, { + data: body, + headers: { Accept: 'application/json' }, + // @note useFetch dismisses its toast id when done and an undefined id + // would dismiss every toast, including other nodes' progress + toastId: `${API_BASE}/${op}`, + ...options, + }) + + if (error) { + throw new Error(error) + } + + // @note a long-running call is answered as a 200 stream whose body may + // carry the error envelope instead of the result + if ( + data && + typeof data.code === 'string' && + typeof data.message === 'string' + ) { + throw new Error(data.message) + } + + return data + }, + [fetch] + ) +} + /** * Context that exposes node-level callbacks and editor metadata to the custom * ReactFlow node, which only otherwise receives its own `data`. */ const EditorContext = createContext({ - busy: false, dragging: false, endDrag: () => {}, zoomImage: () => {}, @@ -167,7 +199,6 @@ function makeImageNode(position) { /** A single image node: prompt, model/size controls and the generated image. */ function ImageNode({ id, data }) { const { - busy, dragging, endDrag, zoomImage, @@ -269,7 +300,6 @@ function ImageNode({ id, data }) { {/* preview */}
{data.assetUrl ? ( - // eslint-disable-next-line @next/next/no-img-element {data.prompt generateNode(id)} > {sourceCount > 0 ? 'Transform' : 'Generate'} @@ -399,6 +429,8 @@ function Flow({ project, assetUrls }) { const confirmYesNo = useConfirmYesNo() + const api = useApi() + const toRfNode = useCallback( (node) => ({ id: node.id, @@ -429,7 +461,6 @@ function Flow({ project, assetUrls }) { project.edges || [] ) - const [busy, setBusy] = useState(false) const [saving, setSaving] = useState(false) const [paneDragActive, setPaneDragActive] = useState(false) const [minimap, setMinimap] = useState(true) @@ -670,13 +701,18 @@ function Flow({ project, assetUrls }) { const updateNodeData = useCallback( (nodeId, patch) => { - setNodes((current) => + const apply = (current) => current.map((node) => node.id === nodeId ? { ...node, data: { ...node.data, ...patch } } : node ) - ) + + // @note also patch the live ref so a persist issued right after (before + // React re-renders) already sees the change + nodesRef.current = apply(nodesRef.current) + + setNodes(apply) }, [setNodes] ) @@ -781,27 +817,27 @@ function Flow({ project, assetUrls }) { setNodes((current) => [...current, makeImageNode(visibleCenterPosition())]) }, [setNodes, visibleCenterPosition, pushHistory]) + // @note saves run one after another; concurrent generations each persist on + // completion and an earlier, staler payload must not land after a newer one + const persistQueueRef = useRef(Promise.resolve()) + /** Serializes the current graph (without transient fields) and persists it. */ - const persist = useCallback(async () => { - const payload = buildGraphPayload(nodesRef.current, edgesRef.current) + const persist = useCallback(() => { + const run = persistQueueRef.current + .catch(() => {}) + .then(async () => { + const payload = buildGraphPayload(nodesRef.current, edgesRef.current) - const result = await saveProject({ - projectId, - nodes: payload.nodes, - edges: payload.edges, - }) + await api('save', { projectId, ...payload }) - if (!result) { - return throwUnprocessableEntity('Unexpected action result') - } + // @note remember what we saved so the unsaved-changes indicator clears + setSavedSnapshot(JSON.stringify(payload)) + }) - if ('error' in result) { - throw errorToErrorResponse(result.error) - } + persistQueueRef.current = run - // @note remember what we saved so the unsaved-changes indicator clears - setSavedSnapshot(JSON.stringify(payload)) - }, [projectId]) + return run + }, [api, projectId]) const onSave = useCallback(async () => { setSaving(true) @@ -845,34 +881,34 @@ function Flow({ project, assetUrls }) { pendingLabel: activity, error: undefined, }) - setBusy(true) const toastId = toast.loading(activity, {}) try { + // @note never retry a generation - a retried request would bill twice const result = sourceAssetPaths.length > 0 - ? await editImageNode({ - projectId, - prompt: node.data.prompt, - sourceAssetPaths, - model: node.data.model, - size: node.data.size, - }) - : await generateImage({ - projectId, - prompt: node.data.prompt, - model: node.data.model, - size: node.data.size, - }) - - if (!result) { - return throwUnprocessableEntity('Unexpected action result') - } - - if ('error' in result) { - throw errorToErrorResponse(result.error) - } + ? await api( + 'edit', + { + projectId, + prompt: node.data.prompt, + sourceAssetPaths, + model: node.data.model, + size: node.data.size, + }, + { retries: 0 } + ) + : await api( + 'generate', + { + projectId, + prompt: node.data.prompt, + model: node.data.model, + size: node.data.size, + }, + { retries: 0 } + ) updateNodeData(nodeId, { assetPath: result.assetPath, @@ -890,11 +926,9 @@ function Flow({ project, assetUrls }) { updateNodeData(nodeId, { status: 'error', error: e.message }) toast.error(e.message, { id: toastId }) - } finally { - setBusy(false) } }, - [projectId, updateNodeData, persist, pushHistory] + [api, projectId, updateNodeData, persist, pushHistory] ) /** @@ -903,18 +937,14 @@ function Flow({ project, assetUrls }) { */ const uploadFile = useCallback( async (file) => { - const meta = await createAssetUpload({ - projectId, - file: { type: file.type, size: file.size }, - }) - - if (!meta) { - return throwUnprocessableEntity('Unexpected action result') - } - - if ('error' in meta) { - throw errorToErrorResponse(meta.error) - } + const meta = await api( + 'upload', + { + projectId, + file: { type: file.type, size: file.size }, + }, + { retries: 0 } + ) if (!meta.uploadRequest) { throw new Error('Upload is not available') @@ -932,14 +962,14 @@ function Flow({ project, assetUrls }) { throw new Error('Failed to upload image') } - const urls = await getAssetUrls({ projectId, paths: [meta.path] }) - - const assetUrl = - urls && !('error' in urls) ? urls.assetUrls[meta.path] : undefined + const { assetUrls } = await api('assets', { + projectId, + paths: [meta.path], + }) - return { assetPath: meta.path, assetUrl } + return { assetPath: meta.path, assetUrl: assetUrls?.[meta.path] } }, - [projectId] + [api, projectId] ) /** Creates a new node from a dropped image file at the given flow position. */ @@ -971,8 +1001,6 @@ function Flow({ project, assetUrls }) { }, ]) - setBusy(true) - const toastId = toast.loading('Uploading image…', {}) try { @@ -992,8 +1020,6 @@ function Flow({ project, assetUrls }) { setNodes((current) => current.filter((node) => node.id !== nodeId)) toast.error(e.message, { id: toastId }) - } finally { - setBusy(false) } }, [setNodes, uploadFile, updateNodeData, persist, pushHistory] @@ -1014,7 +1040,6 @@ function Flow({ project, assetUrls }) { pendingLabel: 'Uploading…', error: undefined, }) - setBusy(true) const toastId = toast.loading('Uploading image…', {}) @@ -1036,8 +1061,6 @@ function Flow({ project, assetUrls }) { updateNodeData(nodeId, { status: 'error', error: e.message }) toast.error(e.message, { id: toastId }) - } finally { - setBusy(false) } }, [updateNodeData, uploadFile, persist, pushHistory] @@ -1204,7 +1227,6 @@ function Flow({ project, assetUrls }) { const contextValue = useMemo( () => ({ - busy, dragging: paneDragActive, endDrag, zoomImage, @@ -1215,7 +1237,6 @@ function Flow({ project, assetUrls }) { setNodeImageFromFile, }), [ - busy, paneDragActive, endDrag, zoomImage, @@ -1351,7 +1372,7 @@ function Flow({ project, assetUrls }) { > - {/* eslint-disable-next-line @next/next/no-img-element */} + {} Full size> { - const entries = await Promise.all( - [...new Set(paths)].map(async (path) => { - try { - const { url } = await client.space.storage.download(spaceId, path) - - return [path, url] as const - } catch { - return null - } - }) - ) - - return Object.fromEntries( - entries.filter((entry): entry is readonly [string, string] => entry !== null) - ) -} - -/** Ensures the account is within limits before spending on image generation. */ -async function assertWithinLimits(session: Session): Promise { - const user = await fastGetUserById(session.user.id) - - if (!user) { - throw new Error('User not found') - } - - if (!(await accountLimitsOk(user, ['token', 'image']))) { - throw new Error('You have reached your usage limit.') - } -} - -/** Records token and image usage for a single generation, mirroring the API. */ -async function recordUsage( - session: Session, - usage: { inputTokens: number; outputTokens: number; model: string }, - count: number, - reason: 'image/create' | 'image/edit' -): Promise { - const usageRecorder = new Usage() - - usageRecorder.addImageTokens(usage.inputTokens, usage.model, 'input') - usageRecorder.addImageTokens(usage.outputTokens, usage.model, 'output') - - await usageRecorder.recordBaseTokens({ - user: { id: session.user.id }, - meta: { reason }, - }) - - await recordImageUsage({ - user: { id: session.user.id }, - count, - model: usage.model, - meta: { reason }, - }) -} +import { buildAssetUrls, ensureSpace, readProject } from '../../space' /** * Loads a project graph along with freshly signed URLs for every asset * referenced by its nodes. + * + * @note this is the only editor operation left as a server action: the page + * calls it during server rendering. Everything the editor triggers from the + * browser goes through the route handlers in `../../api` so requests can run + * concurrently. */ export const getProject = appContactActionHandler( APP_NAME, @@ -144,221 +47,3 @@ export const getProject = appContactActionHandler( return { project, assetUrls } } ) - -/** Persists the full graph (nodes + edges) for a project. */ -export const saveProject = appContactActionHandler( - APP_NAME, - CONTACT_NAMESPACE, - ConfigSchema, - z.object({ - projectId: z.string(), - nodes: z.array(NodeSchema), - edges: z.array(EdgeSchema), - }), - async ( - _config, - session, - contact, - { projectId, nodes, edges } - ): Promise<{ id: string }> => { - const { client, spaceId } = await ensureSpace(session, contact) - - const project = await readProject(client, spaceId, projectId) - - if (!project) { - throw new Error('Project not found') - } - - await writeProject(client, spaceId, { - ...project, - nodes, - edges, - updatedAt: new Date().toISOString(), - }) - - return { id: projectId } - } -) - -/** - * Refreshes presigned download URLs for the requested asset paths. Used when - * URLs returned by `getProject` expire during a long editing session. - */ -export const getAssetUrls = appContactActionHandler( - APP_NAME, - CONTACT_NAMESPACE, - ConfigSchema, - z.object({ - projectId: z.string(), - paths: z.array(z.string()), - }), - async ( - _config, - session, - contact, - { paths } - ): Promise<{ assetUrls: Record }> => { - const { client, spaceId } = await ensureSpace(session, contact) - - const assetUrls = await buildAssetUrls(client, spaceId, paths) - - return { assetUrls } - } -) - -/** - * Mints a presigned upload request for a dropped image file. The browser PUTs - * the bytes directly to storage, so the file never passes through the server - * action body. Returns the asset path and the upload request to perform. - */ -export const createAssetUpload = appContactActionHandler( - APP_NAME, - CONTACT_NAMESPACE, - ConfigSchema, - z.object({ - projectId: z.string(), - file: z.object({ - type: z.string(), - size: z.number(), - }), - }), - async ( - _config, - session, - contact, - { projectId, file } - ): Promise<{ - path: string - uploadRequest?: { method: string; url: string; headers: object } - }> => { - const { client, spaceId } = await ensureSpace(session, contact) - - const path = assetPath(projectId, randomUUID(), extFromMimeType(file.type)) - - const data = await client.space.storage.upload(spaceId, path, { - file: { - type: file.type || 'application/octet-stream', - size: file.size, - }, - }) - - return { path: data.path, uploadRequest: data.uploadRequest } - } -) - -/** - * Generates a brand new image from a prompt and stores it as an asset in the - * project's assets folder. Returns the stored path and a download URL. - */ -export const generateImage = appContactActionHandler( - APP_NAME, - CONTACT_NAMESPACE, - ConfigSchema, - z.object({ - projectId: z.string(), - prompt: z.string().min(1), - model: z.string().optional(), - size: z.string().optional(), - }), - async ( - _config, - session, - contact, - { projectId, prompt, model, size } - ): Promise<{ assetPath: string; assetUrl: string }> => { - const { client, spaceId } = await ensureSpace(session, contact) - - await assertWithinLimits(session) - - const { urls, usage } = await createImage(prompt, { - model: buildModelId(model || DEFAULT_MODEL, size || DEFAULT_SIZE), - user: session.user.id, - }) - - await recordUsage(session, usage, urls.length, 'image/create') - - const [url] = urls - - if (!url) { - throw new Error('Image generation returned no result') - } - - return storeAsset(client, spaceId, projectId, url) - } -) - -/** - * Creates a new image by transforming one or more source images with a prompt - * (the `image/edit` flow). The new image is stored as a fresh asset. - */ -export const editImageNode = appContactActionHandler( - APP_NAME, - CONTACT_NAMESPACE, - ConfigSchema, - z.object({ - projectId: z.string(), - prompt: z.string().min(1), - sourceAssetPaths: z.array(z.string()).min(1), - model: z.string().optional(), - size: z.string().optional(), - }), - async ( - _config, - session, - contact, - { projectId, prompt, sourceAssetPaths, model, size } - ): Promise<{ assetPath: string; assetUrl: string }> => { - const { client, spaceId } = await ensureSpace(session, contact) - - await assertWithinLimits(session) - - // @note fetch each source asset as a blob to feed the edit model - const imageBlobs = await Promise.all( - sourceAssetPaths.map(async (path) => { - const { url } = await client.space.storage.download(spaceId, path) - - const response = await fetch(url) - - if (!response.ok) { - throw new Error('Failed to load a source image') - } - - return response.blob() - }) - ) - - const { urls, usage } = await editImage(prompt, imageBlobs, { - model: buildModelId(model || DEFAULT_MODEL, size || DEFAULT_SIZE), - user: session.user.id, - }) - - await recordUsage(session, usage, urls.length, 'image/edit') - - const [url] = urls - - if (!url) { - throw new Error('Image edit returned no result') - } - - return storeAsset(client, spaceId, projectId, url) - } -) - -/** - * Ingests a generated image URL into the project's assets folder and returns - * the stored path together with a download URL for immediate display. - */ -async function storeAsset( - client: Client, - spaceId: string, - projectId: string, - url: string -): Promise<{ assetPath: string; assetUrl: string }> { - const path = assetPath(projectId, randomUUID()) - - await client.space.storage.upload(spaceId, path, { file: url }) - - const { url: assetUrl } = await client.space.storage.download(spaceId, path) - - return { assetPath: path, assetUrl } -} diff --git a/platform/app/apps/(adhoc)/f2a9c7d4/README.md b/platform/app/apps/(adhoc)/f2a9c7d4/README.md index 23454ee..a394cbe 100644 --- a/platform/app/apps/(adhoc)/f2a9c7d4/README.md +++ b/platform/app/apps/(adhoc)/f2a9c7d4/README.md @@ -11,17 +11,31 @@ Compose AI images on a canvas - generate from prompts and transform images into ``` f2a9c7d4/ - app.manifest # App configuration - const.ts # APP_NAME and CONTACT_NAMESPACE exports - config.ts # Zod schema for app configuration - layout.jsx # App layout with standard header/footer - page.tsx # Server component for initial data loading - components.jsx # Client components - server.ts # Server-side logic - README.md # This file + app.manifest # App configuration + const.ts # APP_NAME, CONTACT_NAMESPACE and storage layout + config.ts # Zod schema for app configuration + lib.ts # Shared types and pure helpers (client-safe) + space.ts # Server-only space and project storage helpers + server.ts # Project list actions (list, create, update, delete) + api.ts # Editor route handlers (save, assets, upload, generate, edit) + api//route.ts # POST endpoints exposing the handlers in api.ts + layout.jsx # Minimal root layout + components.jsx # Project list client components + (home)/ # Project list route with the standard app chrome + (project)/[projectId]/ # Fullscreen canvas editor + server.ts # getProject action used by the page render + components.jsx # ReactFlow editor calling the api routes + README.md # This file ``` ## Key Behavior -- Exposes Media Graph functionality through the ChatBotKit platform interface -- Provides comprehensive features for managing and working with Media Graph +- Each user gets a space (aliased by app and contact fingerprint) holding one + folder per project with a `project.json` graph and an `assets` folder +- The project list uses server actions; the editor calls the route handlers + under `/apps/f2a9c7d4/api/*` instead. Server actions are queued one at a + time by the client, which serialised image generation across nodes. Route + handlers run as independent requests, so several nodes can generate at once +- The editor saves through a client-side queue so a slower save issued + earlier cannot overwrite a newer graph +- Image generation and edit calls are never retried to avoid billing twice diff --git a/platform/app/apps/(adhoc)/f2a9c7d4/api.ts b/platform/app/apps/(adhoc)/f2a9c7d4/api.ts new file mode 100644 index 0000000..4124d0b --- /dev/null +++ b/platform/app/apps/(adhoc)/f2a9c7d4/api.ts @@ -0,0 +1,306 @@ +import type { Session } from 'next-auth' + +import { appContactRouteHandler } from '@/lib/app.route' +import fetch from '@/lib/fetch' +import { createImage, editImage } from '@/lib/image' +import { accountLimitsOk } from '@/lib/limit.core' +import { Usage } from '@/lib/usage.model' +import { recordImageUsage } from '@/lib/usage.record' +import { fastGetUserById } from '@/lib/user.get' +import { z } from '@/lib/zod.schema' + +import ConfigSchema from './config' +import { APP_NAME, CONTACT_NAMESPACE } from './const' +import { + DEFAULT_MODEL, + DEFAULT_SIZE, + assetPath, + buildModelId, + extFromMimeType, +} from './lib' +import type { Client } from './space' +import { buildAssetUrls, ensureSpace, readProject, writeProject } from './space' + +import { randomUUID } from 'crypto' + +/** + * @file Route handlers for the editor operations. These run as independent + * HTTP requests so several image generations can be in flight at once, which + * server actions cannot do because the client serialises them. + */ + +const NodeSchema = z.object({ + id: z.string(), + type: z.literal('image'), + position: z.object({ x: z.number(), y: z.number() }), + data: z.object({ + prompt: z.string(), + model: z.string(), + size: z.string(), + assetPath: z.string().nullable(), + status: z.enum(['empty', 'pending', 'ready', 'error']), + error: z.string().optional(), + }), +}) + +const EdgeSchema = z.object({ + id: z.string(), + source: z.string(), + target: z.string(), +}) + +/** Ensures the account is within limits before spending on image generation. */ +async function assertWithinLimits(session: Session): Promise { + const user = await fastGetUserById(session.user.id) + + if (!user) { + throw new Error('User not found') + } + + if (!(await accountLimitsOk(user, ['token', 'image']))) { + throw new Error('You have reached your usage limit.') + } +} + +/** Records token and image usage for a single generation, mirroring the API. */ +async function recordUsage( + session: Session, + usage: { inputTokens: number; outputTokens: number; model: string }, + count: number, + reason: 'image/create' | 'image/edit' +): Promise { + const usageRecorder = new Usage() + + usageRecorder.addImageTokens(usage.inputTokens, usage.model, 'input') + usageRecorder.addImageTokens(usage.outputTokens, usage.model, 'output') + + await usageRecorder.recordBaseTokens({ + user: { id: session.user.id }, + meta: { reason }, + }) + + await recordImageUsage({ + user: { id: session.user.id }, + count, + model: usage.model, + meta: { reason }, + }) +} + +/** + * Ingests a generated image URL into the project's assets folder and returns + * the stored path together with a download URL for immediate display. + */ +async function storeAsset( + client: Client, + spaceId: string, + projectId: string, + url: string +): Promise<{ assetPath: string; assetUrl: string }> { + const path = assetPath(projectId, randomUUID()) + + await client.space.storage.upload(spaceId, path, { file: url }) + + const { url: assetUrl } = await client.space.storage.download(spaceId, path) + + return { assetPath: path, assetUrl } +} + +/** Persists the full graph (nodes + edges) for a project. */ +export const saveProject = appContactRouteHandler( + APP_NAME, + CONTACT_NAMESPACE, + ConfigSchema, + z.object({ + projectId: z.string(), + nodes: z.array(NodeSchema), + edges: z.array(EdgeSchema), + }), + async ( + _config, + session, + contact, + { projectId, nodes, edges } + ): Promise<{ id: string }> => { + const { client, spaceId } = await ensureSpace(session, contact) + + const project = await readProject(client, spaceId, projectId) + + if (!project) { + throw new Error('Project not found') + } + + await writeProject(client, spaceId, { + ...project, + nodes, + edges, + updatedAt: new Date().toISOString(), + }) + + return { id: projectId } + } +) + +/** + * Refreshes presigned download URLs for the requested asset paths. Used when + * URLs returned by `getProject` expire during a long editing session. + */ +export const getAssetUrls = appContactRouteHandler( + APP_NAME, + CONTACT_NAMESPACE, + ConfigSchema, + z.object({ + projectId: z.string(), + paths: z.array(z.string()), + }), + async ( + _config, + session, + contact, + { paths } + ): Promise<{ assetUrls: Record }> => { + const { client, spaceId } = await ensureSpace(session, contact) + + const assetUrls = await buildAssetUrls(client, spaceId, paths) + + return { assetUrls } + } +) + +/** + * Mints a presigned upload request for a dropped image file. The browser PUTs + * the bytes directly to storage, so the file never passes through the server. + * Returns the asset path and the upload request to perform. + */ +export const createAssetUpload = appContactRouteHandler( + APP_NAME, + CONTACT_NAMESPACE, + ConfigSchema, + z.object({ + projectId: z.string(), + file: z.object({ + type: z.string(), + size: z.number(), + }), + }), + async ( + _config, + session, + contact, + { projectId, file } + ): Promise<{ + path: string + uploadRequest?: { method: string; url: string; headers: object } + }> => { + const { client, spaceId } = await ensureSpace(session, contact) + + const path = assetPath(projectId, randomUUID(), extFromMimeType(file.type)) + + const data = await client.space.storage.upload(spaceId, path, { + file: { + type: file.type || 'application/octet-stream', + size: file.size, + }, + }) + + return { path: data.path, uploadRequest: data.uploadRequest } + } +) + +/** + * Generates a brand new image from a prompt and stores it as an asset in the + * project's assets folder. Returns the stored path and a download URL. + */ +export const generateImage = appContactRouteHandler( + APP_NAME, + CONTACT_NAMESPACE, + ConfigSchema, + z.object({ + projectId: z.string(), + prompt: z.string().min(1), + model: z.string().optional(), + size: z.string().optional(), + }), + async ( + _config, + session, + contact, + { projectId, prompt, model, size } + ): Promise<{ assetPath: string; assetUrl: string }> => { + const { client, spaceId } = await ensureSpace(session, contact) + + await assertWithinLimits(session) + + const { urls, usage } = await createImage(prompt, { + model: buildModelId(model || DEFAULT_MODEL, size || DEFAULT_SIZE), + user: session.user.id, + }) + + await recordUsage(session, usage, urls.length, 'image/create') + + const [url] = urls + + if (!url) { + throw new Error('Image generation returned no result') + } + + return storeAsset(client, spaceId, projectId, url) + } +) + +/** + * Creates a new image by transforming one or more source images with a prompt + * (the `image/edit` flow). The new image is stored as a fresh asset. + */ +export const editImageNode = appContactRouteHandler( + APP_NAME, + CONTACT_NAMESPACE, + ConfigSchema, + z.object({ + projectId: z.string(), + prompt: z.string().min(1), + sourceAssetPaths: z.array(z.string()).min(1), + model: z.string().optional(), + size: z.string().optional(), + }), + async ( + _config, + session, + contact, + { projectId, prompt, sourceAssetPaths, model, size } + ): Promise<{ assetPath: string; assetUrl: string }> => { + const { client, spaceId } = await ensureSpace(session, contact) + + await assertWithinLimits(session) + + // @note fetch each source asset as a blob to feed the edit model + const imageBlobs = await Promise.all( + sourceAssetPaths.map(async (path) => { + const { url } = await client.space.storage.download(spaceId, path) + + const response = await fetch(url) + + if (!response.ok) { + throw new Error('Failed to load a source image') + } + + return response.blob() + }) + ) + + const { urls, usage } = await editImage(prompt, imageBlobs, { + model: buildModelId(model || DEFAULT_MODEL, size || DEFAULT_SIZE), + user: session.user.id, + }) + + await recordUsage(session, usage, urls.length, 'image/edit') + + const [url] = urls + + if (!url) { + throw new Error('Image edit returned no result') + } + + return storeAsset(client, spaceId, projectId, url) + } +) diff --git a/platform/app/apps/(adhoc)/f2a9c7d4/api.utest.js b/platform/app/apps/(adhoc)/f2a9c7d4/api.utest.js new file mode 100644 index 0000000..e73c5e1 --- /dev/null +++ b/platform/app/apps/(adhoc)/f2a9c7d4/api.utest.js @@ -0,0 +1,290 @@ +import fetch from '@/lib/fetch' +import { createImage, editImage } from '@/lib/image' +import { accountLimitsOk } from '@/lib/limit.core' +import { recordImageUsage } from '@/lib/usage.record' +import { fastGetUserById } from '@/lib/user.get' + +import { + createAssetUpload, + editImageNode, + generateImage, + getAssetUrls, + saveProject, +} from './api' +import { buildAssetUrls, ensureSpace, readProject, writeProject } from './space' + +// @note the route handler is reduced to a passthrough so each handler can be +// invoked with its input directly; the session/contact plumbing has its own +// tests in lib/app.route.utest.js +jest.mock('@/lib/app.route', () => ({ + appContactRouteHandler: + (_app, _namespace, _configSchema, inputSchema, handler) => + async (input) => { + return handler( + {}, + { user: { id: 'user-123' } }, + { id: 'contact-123' }, + await inputSchema.parseAsync(input), + {} + ) + }, +})) + +jest.mock('crypto', () => ({ + randomUUID: jest.fn(() => 'uuid-1'), +})) + +jest.mock('@/lib/fetch', () => jest.fn()) + +jest.mock('@/lib/image', () => ({ + createImage: jest.fn(), + editImage: jest.fn(), +})) + +jest.mock('@/lib/limit.core', () => ({ + accountLimitsOk: jest.fn(), +})) + +const mockUsage = { + addImageTokens: jest.fn(), + recordBaseTokens: jest.fn(), +} + +jest.mock('@/lib/usage.model', () => ({ + Usage: jest.fn(() => mockUsage), +})) + +jest.mock('@/lib/usage.record', () => ({ + recordImageUsage: jest.fn(), +})) + +jest.mock('@/lib/user.get', () => ({ + fastGetUserById: jest.fn(), +})) + +jest.mock('./space', () => ({ + buildAssetUrls: jest.fn(), + ensureSpace: jest.fn(), + readProject: jest.fn(), + writeProject: jest.fn(), +})) + +describe('f2a9c7d4/api', () => { + let client + + beforeEach(() => { + jest.clearAllMocks() + + client = { + space: { + storage: { + upload: jest.fn().mockResolvedValue({ + path: 'projects/p1/assets/uuid-1.png', + uploadRequest: { method: 'PUT', url: 'https://put', headers: {} }, + }), + download: jest + .fn() + .mockImplementation(async (_spaceId, path) => ({ + url: `https://get/${path}`, + })), + }, + }, + } + + ensureSpace.mockResolvedValue({ client, spaceId: 'space-1' }) + fastGetUserById.mockResolvedValue({ id: 'user-123' }) + accountLimitsOk.mockResolvedValue(true) + }) + + describe('generateImage', () => { + it('generates, records usage and stores the asset', async () => { + createImage.mockResolvedValue({ + urls: ['https://generated/1.png'], + usage: { inputTokens: 10, outputTokens: 20, model: 'gpt-image-2' }, + }) + + const result = await generateImage({ + projectId: 'p1', + prompt: 'a cat', + model: 'gpt-image-2', + size: '1024x1024', + }) + + expect(accountLimitsOk).toHaveBeenCalledWith({ id: 'user-123' }, [ + 'token', + 'image', + ]) + expect(createImage).toHaveBeenCalledWith('a cat', { + model: 'gpt-image-2/size=1024x1024', + user: 'user-123', + }) + expect(mockUsage.recordBaseTokens).toHaveBeenCalledWith({ + user: { id: 'user-123' }, + meta: { reason: 'image/create' }, + }) + expect(recordImageUsage).toHaveBeenCalledWith({ + user: { id: 'user-123' }, + count: 1, + model: 'gpt-image-2', + meta: { reason: 'image/create' }, + }) + expect(client.space.storage.upload).toHaveBeenCalledWith( + 'space-1', + 'projects/p1/assets/uuid-1.png', + { file: 'https://generated/1.png' } + ) + expect(result).toEqual({ + assetPath: 'projects/p1/assets/uuid-1.png', + assetUrl: 'https://get/projects/p1/assets/uuid-1.png', + }) + }) + + it('refuses to generate when the account is over its limits', async () => { + accountLimitsOk.mockResolvedValue(false) + + await expect( + generateImage({ projectId: 'p1', prompt: 'a cat' }) + ).rejects.toThrow('You have reached your usage limit.') + + expect(createImage).not.toHaveBeenCalled() + }) + + it('rejects an empty prompt at the schema boundary', async () => { + await expect( + generateImage({ projectId: 'p1', prompt: '' }) + ).rejects.toThrow() + + expect(createImage).not.toHaveBeenCalled() + }) + }) + + describe('editImageNode', () => { + it('feeds every source asset to the edit model', async () => { + const blob = { size: 3 } + + fetch.mockResolvedValue({ ok: true, blob: async () => blob }) + + editImage.mockResolvedValue({ + urls: ['https://generated/2.png'], + usage: { inputTokens: 1, outputTokens: 2, model: 'gpt-image-2' }, + }) + + const result = await editImageNode({ + projectId: 'p1', + prompt: 'make it blue', + sourceAssetPaths: ['projects/p1/assets/a.png', 'projects/p1/assets/b.png'], + }) + + expect(client.space.storage.download).toHaveBeenCalledWith( + 'space-1', + 'projects/p1/assets/a.png' + ) + expect(client.space.storage.download).toHaveBeenCalledWith( + 'space-1', + 'projects/p1/assets/b.png' + ) + expect(editImage).toHaveBeenCalledWith('make it blue', [blob, blob], { + model: 'gpt-image-2', + user: 'user-123', + }) + expect(recordImageUsage).toHaveBeenCalledWith( + expect.objectContaining({ meta: { reason: 'image/edit' } }) + ) + expect(result.assetPath).toBe('projects/p1/assets/uuid-1.png') + }) + + it('fails when a source image cannot be loaded', async () => { + fetch.mockResolvedValue({ ok: false }) + + await expect( + editImageNode({ + projectId: 'p1', + prompt: 'make it blue', + sourceAssetPaths: ['projects/p1/assets/a.png'], + }) + ).rejects.toThrow('Failed to load a source image') + + expect(editImage).not.toHaveBeenCalled() + }) + }) + + describe('saveProject', () => { + const nodes = [ + { + id: 'n1', + type: 'image', + position: { x: 0, y: 0 }, + data: { + prompt: 'a cat', + model: 'gpt-image-2', + size: 'auto', + assetPath: null, + status: 'empty', + }, + }, + ] + + it('writes the graph onto the stored project', async () => { + readProject.mockResolvedValue({ + id: 'p1', + name: 'Project', + nodes: [], + edges: [], + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + }) + + const result = await saveProject({ projectId: 'p1', nodes, edges: [] }) + + expect(writeProject).toHaveBeenCalledWith( + client, + 'space-1', + expect.objectContaining({ id: 'p1', name: 'Project', nodes, edges: [] }) + ) + expect(writeProject.mock.calls[0][2].updatedAt).not.toBe( + '2026-01-01T00:00:00.000Z' + ) + expect(result).toEqual({ id: 'p1' }) + }) + + it('fails when the project does not exist', async () => { + readProject.mockResolvedValue(null) + + await expect( + saveProject({ projectId: 'missing', nodes, edges: [] }) + ).rejects.toThrow('Project not found') + + expect(writeProject).not.toHaveBeenCalled() + }) + }) + + describe('createAssetUpload', () => { + it('mints an upload request with the extension of the mime type', async () => { + const result = await createAssetUpload({ + projectId: 'p1', + file: { type: 'image/webp', size: 42 }, + }) + + expect(client.space.storage.upload).toHaveBeenCalledWith( + 'space-1', + 'projects/p1/assets/uuid-1.webp', + { file: { type: 'image/webp', size: 42 } } + ) + expect(result).toEqual({ + path: 'projects/p1/assets/uuid-1.png', + uploadRequest: { method: 'PUT', url: 'https://put', headers: {} }, + }) + }) + }) + + describe('getAssetUrls', () => { + it('signs the requested paths', async () => { + buildAssetUrls.mockResolvedValue({ 'a.png': 'https://get/a.png' }) + + const result = await getAssetUrls({ projectId: 'p1', paths: ['a.png'] }) + + expect(buildAssetUrls).toHaveBeenCalledWith(client, 'space-1', ['a.png']) + expect(result).toEqual({ assetUrls: { 'a.png': 'https://get/a.png' } }) + }) + }) +}) diff --git a/platform/app/apps/(adhoc)/f2a9c7d4/api/assets/route.ts b/platform/app/apps/(adhoc)/f2a9c7d4/api/assets/route.ts new file mode 100644 index 0000000..27df6e4 --- /dev/null +++ b/platform/app/apps/(adhoc)/f2a9c7d4/api/assets/route.ts @@ -0,0 +1,7 @@ +import { getAssetUrls } from '../../api' + +export const POST = getAssetUrls + +export const maxDuration = 800 + +export const dynamic = 'force-dynamic' diff --git a/platform/app/apps/(adhoc)/f2a9c7d4/api/edit/route.ts b/platform/app/apps/(adhoc)/f2a9c7d4/api/edit/route.ts new file mode 100644 index 0000000..0dd5f66 --- /dev/null +++ b/platform/app/apps/(adhoc)/f2a9c7d4/api/edit/route.ts @@ -0,0 +1,7 @@ +import { editImageNode } from '../../api' + +export const POST = editImageNode + +export const maxDuration = 800 + +export const dynamic = 'force-dynamic' diff --git a/platform/app/apps/(adhoc)/f2a9c7d4/api/generate/route.ts b/platform/app/apps/(adhoc)/f2a9c7d4/api/generate/route.ts new file mode 100644 index 0000000..4e0e8cc --- /dev/null +++ b/platform/app/apps/(adhoc)/f2a9c7d4/api/generate/route.ts @@ -0,0 +1,7 @@ +import { generateImage } from '../../api' + +export const POST = generateImage + +export const maxDuration = 800 + +export const dynamic = 'force-dynamic' diff --git a/platform/app/apps/(adhoc)/f2a9c7d4/api/save/route.ts b/platform/app/apps/(adhoc)/f2a9c7d4/api/save/route.ts new file mode 100644 index 0000000..c327af5 --- /dev/null +++ b/platform/app/apps/(adhoc)/f2a9c7d4/api/save/route.ts @@ -0,0 +1,7 @@ +import { saveProject } from '../../api' + +export const POST = saveProject + +export const maxDuration = 800 + +export const dynamic = 'force-dynamic' diff --git a/platform/app/apps/(adhoc)/f2a9c7d4/api/upload/route.ts b/platform/app/apps/(adhoc)/f2a9c7d4/api/upload/route.ts new file mode 100644 index 0000000..ed7d519 --- /dev/null +++ b/platform/app/apps/(adhoc)/f2a9c7d4/api/upload/route.ts @@ -0,0 +1,7 @@ +import { createAssetUpload } from '../../api' + +export const POST = createAssetUpload + +export const maxDuration = 800 + +export const dynamic = 'force-dynamic' diff --git a/platform/app/apps/(adhoc)/f2a9c7d4/space.ts b/platform/app/apps/(adhoc)/f2a9c7d4/space.ts index acad029..24821a3 100644 --- a/platform/app/apps/(adhoc)/f2a9c7d4/space.ts +++ b/platform/app/apps/(adhoc)/f2a9c7d4/space.ts @@ -145,3 +145,31 @@ export async function listProjectIds( return [] } } + +/** + * Builds presigned download URLs for the given asset paths. Returns a map keyed + * by asset path so the client can render images without exposing storage. + */ +export async function buildAssetUrls( + client: Client, + spaceId: string, + paths: string[] +): Promise> { + const entries = await Promise.all( + [...new Set(paths)].map(async (path) => { + try { + const { url } = await client.space.storage.download(spaceId, path) + + return [path, url] as const + } catch { + return null + } + }) + ) + + return Object.fromEntries( + entries.filter( + (entry): entry is readonly [string, string] => entry !== null + ) + ) +} diff --git a/platform/components/DynamicIcon.jsx b/platform/components/DynamicIcon.jsx index 660ce8c..e641c99 100644 --- a/platform/components/DynamicIcon.jsx +++ b/platform/components/DynamicIcon.jsx @@ -9,6 +9,7 @@ import { tryHash } from '@/lib/url' import Emoji from '@/components/Emoji' import GravatarIcon from '@/components/GravatarIcon' +import useHydrated from '@/hooks/useHydrated' import useTheme from '@/hooks/useTheme' import clsx from 'clsx' @@ -200,6 +201,13 @@ export default function DynamicIcon({ }) { const { theme } = useTheme() + // @note the server never knows the theme and renders the light variant; the + // client must render the same markup on its first pass or the dark variant's + // filter style is reported as a hydration mismatch + const hydrated = useHydrated() + + const effectiveTheme = hydrated ? theme : undefined + const [fallbackComponent, setFallbackComponent] = useState(null) let [icon, hash] = useMemo(() => { @@ -217,7 +225,7 @@ export default function DynamicIcon({ dark = dark.split('#')[0].trim() } - if (theme === 'dark') { + if (effectiveTheme === 'dark') { return [dark, darkHash?.slice(1) || ''] } else { return [light, lightHash?.slice(1) || ''] @@ -225,7 +233,7 @@ export default function DynamicIcon({ } else { return [_icon, ''] } - }, [_icon, theme]) + }, [_icon, effectiveTheme]) const className = useMemo(() => { return clsx('dynamic-icon', _className) diff --git a/platform/components/DynamicIcon.utest.jsx b/platform/components/DynamicIcon.utest.jsx index 3ff4576..379f47f 100644 --- a/platform/components/DynamicIcon.utest.jsx +++ b/platform/components/DynamicIcon.utest.jsx @@ -1,4 +1,20 @@ -import { dynamicIconToUrl } from './DynamicIcon' +import useHydrated from '@/hooks/useHydrated' +import useTheme from '@/hooks/useTheme' + +import DynamicIcon, { dynamicIconToUrl } from './DynamicIcon' + +import '@testing-library/jest-dom' +import { render } from '@testing-library/react' + +jest.mock('@/hooks/useHydrated', () => ({ + __esModule: true, + default: jest.fn(() => true), +})) + +jest.mock('@/hooks/useTheme', () => ({ + __esModule: true, + default: jest.fn(() => ({ theme: 'light' })), +})) describe('dynamicIconToUrl', () => { it('returns direct URLs unchanged', () => { @@ -35,3 +51,42 @@ describe('dynamicIconToUrl', () => { ) }) }) + +describe('DynamicIcon theme variants', () => { + const icon = '/icon.png;/icon.png#filter=invertGrayscale' + + beforeEach(() => { + jest.clearAllMocks() + }) + + it('renders the light variant on the first client pass even in dark mode', () => { + useTheme.mockReturnValue({ theme: 'dark' }) + useHydrated.mockReturnValue(false) + + const { getByRole } = render() + + expect(getByRole('img')).not.toHaveStyle({ + filter: 'invert(1) grayscale(1)', + }) + }) + + it('renders the dark variant once hydrated', () => { + useTheme.mockReturnValue({ theme: 'dark' }) + useHydrated.mockReturnValue(true) + + const { getByRole } = render() + + expect(getByRole('img')).toHaveStyle({ filter: 'invert(1) grayscale(1)' }) + }) + + it('keeps the light variant when hydrated in light mode', () => { + useTheme.mockReturnValue({ theme: 'light' }) + useHydrated.mockReturnValue(true) + + const { getByRole } = render() + + expect(getByRole('img')).not.toHaveStyle({ + filter: 'invert(1) grayscale(1)', + }) + }) +}) diff --git a/platform/components/Link.jsx b/platform/components/Link.jsx index 8a4ecd1..25c6668 100644 --- a/platform/components/Link.jsx +++ b/platform/components/Link.jsx @@ -59,9 +59,12 @@ export default forwardRef(function Link( return target }, [target]) + // @note the App Router build of next/link rejects the locale prop, and the + // single configured locale needs no fallback - forward it only when a caller + // sets it explicitly const resolvedLocale = useMemo(() => { - return locale || router.locale || router.defaultLocale - }, [locale, router.locale, router.defaultLocale]) + return locale || undefined + }, [locale]) const extraProps = useMemo(() => { switch (true) { @@ -185,7 +188,7 @@ export default forwardRef(function Link( target: resolvedTarget, - locale: resolvedLocale, + ...(resolvedLocale ? { locale: resolvedLocale } : {}), ...props, ...extraProps, diff --git a/platform/components/Link.utest.jsx b/platform/components/Link.utest.jsx index ca31ce5..c6aebd2 100644 --- a/platform/components/Link.utest.jsx +++ b/platform/components/Link.utest.jsx @@ -98,6 +98,31 @@ describe('Link', () => { expect(link).toHaveAttribute('tabindex', '-1') }) + it('does not forward a locale unless one is given', () => { + render( + + Apps + + ) + + expect(screen.getByRole('link', { name: 'Apps' })).not.toHaveAttribute( + 'locale' + ) + }) + + it('forwards an explicit locale', () => { + render( + + Apps + + ) + + expect(screen.getByRole('link', { name: 'Apps' })).toHaveAttribute( + 'locale', + 'fr' + ) + }) + it('forces prefetch immediately and on interval', () => { render( diff --git a/platform/config/models.ts b/platform/config/models.ts index 91ed55f..da91448 100644 --- a/platform/config/models.ts +++ b/platform/config/models.ts @@ -4150,11 +4150,11 @@ export const vercelLanguageModels: Record< maxOutputTokens: Math.floor(262_144 * MAX_OUTPUT_TOKENS_RATIO), pricing: { - tokenRatio: 0.0222, - inputTokenRatio: 0.01, - outputTokenRatio: 0.0222, - inputPrice: 0.14, - outputPrice: 0.4, + tokenRatio: 0.0828, + inputTokenRatio: 0.0707, + outputTokenRatio: 0.0828, + inputPrice: 0.99, + outputPrice: 1.49, }, interactionMaxMessages: DEFAULT_INTERACTION_MAX_MESSAGES, @@ -4334,11 +4334,11 @@ export const vercelLanguageModels: Record< maxOutputTokens: Math.ceil(1_000_000 * MAX_OUTPUT_TOKENS_RATIO), pricing: { - tokenRatio: 0.11, - inputTokenRatio: 0.0471, - outputTokenRatio: 0.11, - inputPrice: 0.66, - outputPrice: 1.98, + tokenRatio: 0.2444, + inputTokenRatio: 0.15, + outputTokenRatio: 0.2444, + inputPrice: 2.1, + outputPrice: 4.4, }, interactionMaxMessages: DEFAULT_INTERACTION_MAX_MESSAGES, @@ -4387,9 +4387,9 @@ export const vercelLanguageModels: Record< // @note the gateway bills the peak rate, not DeepSeek's off-peak list price pricing: { tokenRatio: 0.0667, - inputTokenRatio: 0.0214, + inputTokenRatio: 0.0643, outputTokenRatio: 0.0667, - inputPrice: 0.3, + inputPrice: 0.9, outputPrice: 1.2, }, @@ -4437,11 +4437,11 @@ export const vercelLanguageModels: Record< maxOutputTokens: Math.ceil(1_000_000 * MAX_OUTPUT_TOKENS_RATIO), pricing: { - tokenRatio: 0.0144, - inputTokenRatio: 0.0093, - outputTokenRatio: 0.0144, - inputPrice: 0.13, - outputPrice: 0.26, + tokenRatio: 0.0733, + inputTokenRatio: 0.0314, + outputTokenRatio: 0.0733, + inputPrice: 0.44, + outputPrice: 1.32, }, interactionMaxMessages: DEFAULT_INTERACTION_MAX_MESSAGES, @@ -4736,11 +4736,11 @@ export const vercelLanguageModels: Record< maxOutputTokens: 131_000, pricing: { - tokenRatio: 0.0278, - inputTokenRatio: 0.0107, - outputTokenRatio: 0.0278, - inputPrice: 0.15, - outputPrice: 0.5, + tokenRatio: 0.0833, + inputTokenRatio: 0.0321, + outputTokenRatio: 0.0833, + inputPrice: 0.45, + outputPrice: 1.5, }, interactionMaxMessages: DEFAULT_INTERACTION_MAX_MESSAGES, @@ -4833,11 +4833,11 @@ export const vercelLanguageModels: Record< maxOutputTokens: 128_000, pricing: { - tokenRatio: 0.1417, - inputTokenRatio: 0.0571, - outputTokenRatio: 0.1417, - inputPrice: 0.8, - outputPrice: 2.55, + tokenRatio: 0.2444, + inputTokenRatio: 0.1, + outputTokenRatio: 0.2444, + inputPrice: 1.4, + outputPrice: 4.4, }, interactionMaxMessages: DEFAULT_INTERACTION_MAX_MESSAGES, @@ -5185,11 +5185,11 @@ export const vercelLanguageModels: Record< maxOutputTokens: 128_000, pricing: { - tokenRatio: 0.0667, - inputTokenRatio: 0.0214, - outputTokenRatio: 0.0667, - inputPrice: 0.3, - outputPrice: 1.2, + tokenRatio: 0.1333, + inputTokenRatio: 0.0429, + outputTokenRatio: 0.1333, + inputPrice: 0.6, + outputPrice: 2.4, }, interactionMaxMessages: DEFAULT_INTERACTION_MAX_MESSAGES, @@ -5563,11 +5563,11 @@ export const vercelLanguageModels: Record< maxOutputTokens: 131_000, pricing: { - tokenRatio: 0.0483, - inputTokenRatio: 0.0311, - outputTokenRatio: 0.0483, - inputPrice: 0.435, - outputPrice: 0.87, + tokenRatio: 0.1667, + inputTokenRatio: 0.0714, + outputTokenRatio: 0.1667, + inputPrice: 1, + outputPrice: 3, }, interactionMaxMessages: DEFAULT_INTERACTION_MAX_MESSAGES, @@ -5606,11 +5606,11 @@ export const vercelLanguageModels: Record< maxOutputTokens: 131_000, pricing: { - tokenRatio: 0.0156, - inputTokenRatio: 0.01, - outputTokenRatio: 0.0156, - inputPrice: 0.14, - outputPrice: 0.28, + tokenRatio: 0.1111, + inputTokenRatio: 0.0286, + outputTokenRatio: 0.1111, + inputPrice: 0.4, + outputPrice: 2, }, interactionMaxMessages: DEFAULT_INTERACTION_MAX_MESSAGES, diff --git a/platform/lib/app.route.ts b/platform/lib/app.route.ts index ab6347e..e0f76d3 100644 --- a/platform/lib/app.route.ts +++ b/platform/lib/app.route.ts @@ -1,5 +1,8 @@ import '@/lib/scope.server' +import type { UnwrapPromise } from '@chatbotkit-dev/typescript-utils/promise' + +import { ensureContact } from '@/lib/app.contact' import type { StoreConfig, StoreSession } from '@/lib/app.context' import { getContextAppConfig, @@ -12,32 +15,35 @@ import { getContextRequestHost, runInContext, } from '@/lib/context.store' -import type { Stream, StreamEvent } from '@/lib/stream' -import { withStream } from '@/lib/stream' import { captureException } from '@/lib/error' import { withAny } from '@/lib/method' import { parseRequestSchema } from '@/lib/request' import { throwNotAuthenticated, throwNotAuthorized } from '@/lib/response' +import type { Stream, StreamEvent } from '@/lib/stream' +import { withStream } from '@/lib/stream' import type { ZodSchema } from 'zod' -interface Context { +export interface AppRouteContext { host?: string } /** * This is a helper function that creates an route handler for an app with * session handling and input validation built-in. + * + * @note a resolved value is sent as the stream result while an (async) + * iterable is pushed event by event, so `R` is only constrained for the latter */ -export function appRouteHandler( +export function appRouteHandler( app: string, schema: ZodSchema, fn: ( config: StoreConfig, session: StoreSession, input: T, - context: Context - ) => Promise | AsyncGenerator + context: AppRouteContext + ) => Promise | AsyncGenerator ): (req: Request) => Promise { return withAny( withStream(async (req, stream) => { @@ -92,7 +98,9 @@ export function appRouteHandler( it !== null && (Symbol.asyncIterator in it || Symbol.iterator in it) ) { - for await (const item of it as AsyncIterable | Iterable) { + for await (const item of it as + | AsyncIterable + | Iterable) { await stream.push(item) } } else { @@ -112,3 +120,38 @@ export function appRouteHandler( }) ) as (req: Request) => Promise } + +/** + * This is a helper function that creates a route handler for an app with + * session handling, contact ensuring, config validation and input validation + * built-in. It is the route handler counterpart of `appContactActionHandler`. + */ +export function appContactRouteHandler( + app: string, + namespace: string, + configSchema: ZodSchema, + inputSchema: ZodSchema, + fn: ( + config: U, + session: StoreSession, + contact: UnwrapPromise>, + input: T, + context: AppRouteContext + ) => Promise | AsyncGenerator +): (req: Request) => Promise { + // @note the async wrapper resolves to either the result or the generator, + // and the route handler tells them apart after awaiting + + return appRouteHandler>( + app, + inputSchema, + async (config, session, input, context) => { + const [parsedConfig, contact] = await Promise.all([ + configSchema.parseAsync(config), + ensureContact({ namespace: namespace, session: session, app: app }), + ]) + + return fn(parsedConfig, session, contact, input, context) + } + ) +} diff --git a/platform/lib/app.route.utest.js b/platform/lib/app.route.utest.js index 9102c70..7cb595d 100644 --- a/platform/lib/app.route.utest.js +++ b/platform/lib/app.route.utest.js @@ -1,3 +1,4 @@ +import { ensureContact } from '@/lib/app.contact' import { getContextAppConfig, getContextAppSession } from '@/lib/app.context' import { APP_AUDIENCE } from '@/lib/audience.consts' import { @@ -8,10 +9,14 @@ import { captureException } from '@/lib/error' import { parseRequestSchema } from '@/lib/request' import { throwNotAuthenticated, throwNotAuthorized } from '@/lib/response' -import { appRouteHandler } from './app.route' +import { appContactRouteHandler, appRouteHandler } from './app.route' import { z } from 'zod' +jest.mock('@/lib/app.contact', () => ({ + ensureContact: jest.fn(), +})) + jest.mock('@/lib/app.context', () => ({ getContextAppConfig: jest.fn(), getContextAppSession: jest.fn(), @@ -262,3 +267,106 @@ describe('appRouteHandler', () => { expect(mockStream.result).not.toHaveBeenCalled() }) }) + +describe('appContactRouteHandler', () => { + beforeEach(() => { + jest.clearAllMocks() + getContextFrontendHost.mockReturnValue(undefined) + getContextRequestHost.mockReturnValue(undefined) + }) + + it('should ensure the contact and pass parsed config, contact and input', async () => { + const configSchema = z.object({ flag: z.boolean().default(false) }) + const inputSchema = z.object({ name: z.string() }) + const fn = jest.fn().mockResolvedValue({ ok: true }) + + const mockSession = { payload: { aud: 'other' } } + const mockContact = { id: 'contact-1' } + + getContextAppConfig.mockResolvedValue({}) + getContextAppSession.mockResolvedValue(mockSession) + parseRequestSchema.mockResolvedValue({ name: 'test' }) + ensureContact.mockResolvedValue(mockContact) + + const handler = appContactRouteHandler( + 'test-app', + 'test-namespace', + configSchema, + inputSchema, + fn + ) + const mockReq = new Request('http://localhost/test') + const mockStream = { push: jest.fn(), result: jest.fn(), error: jest.fn() } + + await handler(mockReq, mockStream) + + expect(ensureContact).toHaveBeenCalledWith({ + namespace: 'test-namespace', + session: mockSession, + app: 'test-app', + }) + expect(fn).toHaveBeenCalledWith( + { flag: false }, + mockSession, + mockContact, + { name: 'test' }, + expect.any(Object) + ) + expect(mockStream.result).toHaveBeenCalledWith({ ok: true }) + }) + + it('should stream generator events from the contact handler', async () => { + const fn = jest.fn().mockImplementation(async function* () { + yield { type: 'item', data: { n: 1 } } + }) + + getContextAppConfig.mockResolvedValue({}) + getContextAppSession.mockResolvedValue({ payload: { aud: 'other' } }) + parseRequestSchema.mockResolvedValue({}) + ensureContact.mockResolvedValue({ id: 'contact-1' }) + + const handler = appContactRouteHandler( + 'test-app', + 'test-namespace', + z.object({}), + z.object({}), + fn + ) + const mockReq = new Request('http://localhost/test') + const mockStream = { push: jest.fn(), result: jest.fn(), error: jest.fn() } + + await handler(mockReq, mockStream) + + expect(mockStream.push).toHaveBeenCalledWith({ + type: 'item', + data: { n: 1 }, + }) + expect(mockStream.result).not.toHaveBeenCalled() + }) + + it('should report a contact failure through the stream error', async () => { + const fn = jest.fn() + const contactError = new Error('Contact not found') + + getContextAppConfig.mockResolvedValue({}) + getContextAppSession.mockResolvedValue({ payload: { aud: 'other' } }) + parseRequestSchema.mockResolvedValue({}) + ensureContact.mockRejectedValue(contactError) + + const handler = appContactRouteHandler( + 'test-app', + 'test-namespace', + z.object({}), + z.object({}), + fn + ) + const mockReq = new Request('http://localhost/test') + const mockStream = { push: jest.fn(), result: jest.fn(), error: jest.fn() } + + await handler(mockReq, mockStream) + + expect(fn).not.toHaveBeenCalled() + expect(captureException).toHaveBeenCalledWith(contactError) + expect(mockStream.error).toHaveBeenCalledWith(contactError) + }) +}) diff --git a/platform/lib/auth.adapter.ts b/platform/lib/auth.adapter.ts index acb63d8..0543b8b 100644 --- a/platform/lib/auth.adapter.ts +++ b/platform/lib/auth.adapter.ts @@ -27,10 +27,21 @@ export const adapter: Required = ((adapter) => { // the WHERE clause and the SET clause, causing Prisma to emit an // inefficient `id IN () AND (? = ?)` pattern on MariaDB - return prisma.session.update({ - where: { sessionToken }, - data: rest, - }) + // @note this only bumps the session expiry. NextAuth treats a + // throw here as a lost session and bounces the user to sign-in, so + // a failed bump is reported and ignored: the session was already + // read with a valid expiry and the next request bumps it again. + + try { + return await prisma.session.update({ + where: { sessionToken }, + data: rest, + }) + } catch (e) { + await captureException(e) + + return null + } } } diff --git a/platform/lib/auth.adapter.utest.js b/platform/lib/auth.adapter.utest.js index e07c528..9284a91 100644 --- a/platform/lib/auth.adapter.utest.js +++ b/platform/lib/auth.adapter.utest.js @@ -21,6 +21,9 @@ jest.mock('@/prisma/client', () => ({ verificationToken: { deleteMany: jest.fn(), }, + session: { + update: jest.fn(), + }, }, })) @@ -76,6 +79,41 @@ describe('auth.adapter', () => { }) }) + describe('updateSession', () => { + it('should update the session without the token in the data payload', async () => { + const expires = new Date('2026-10-01T00:00:00.000Z') + + prisma.session.update.mockResolvedValue({ sessionToken: 'tok', expires }) + + const result = await adapter.updateSession({ + sessionToken: 'tok', + expires, + }) + + expect(prisma.session.update).toHaveBeenCalledWith({ + where: { sessionToken: 'tok' }, + data: { expires }, + }) + expect(result).toEqual({ sessionToken: 'tok', expires }) + }) + + it('should report and swallow a failed expiry bump', async () => { + const error = new Error( + 'target: main.-.primary: vttablet: rpc error: code = Aborted desc = transaction ended (transaction committed)' + ) + + prisma.session.update.mockRejectedValue(error) + + const result = await adapter.updateSession({ + sessionToken: 'tok', + expires: new Date(), + }) + + expect(result).toBeNull() + expect(captureException).toHaveBeenCalledWith(error) + }) + }) + describe('createUser', () => { it('should create user and trigger setup event', async () => { const userData = { diff --git a/platform/lib/model.provider.vercel.utest.js b/platform/lib/model.provider.vercel.utest.js index bfadf72..e47b1a5 100644 --- a/platform/lib/model.provider.vercel.utest.js +++ b/platform/lib/model.provider.vercel.utest.js @@ -325,6 +325,85 @@ describeIfConfigured('listModels', () => { } } + // @note the catalogue's single `pricing` block and context figures describe + // whichever backend the gateway chose to list, and change as backends come + // and go. A request is billed at the rate of the backend the gateway routes + // it to, so the configuration is held to the most expensive backend the + // model can reach (within its `gateway.only` pin): no routing decision may + // bill more than the configuration charges. Long-context tiers and + // peak-hour multipliers are left out, as they are for the catalogue price. + // Sizing is held to the backends the same way: the configured context must + // be one a reachable backend actually offers. + async function getEndpointFacts(modelId, config) { + const response = await fetch( + `https://ai-gateway.vercel.sh/v1/models/${modelId}/endpoints`, + { + headers: { + Authorization: `Bearer ${getVercelAPIKey()}`, + 'Content-Type': 'application/json', + }, + } + ) + + if (!response.ok) { + return undefined + } + + const { data } = await response.json() + + const only = config.providerOptions?.gateway?.only + + // endpoint slugs can be longer than the pin slug (`vertexAnthropic` for + // a `vertex` pin), so match on the prefix + const endpoints = (data?.endpoints || []).filter( + (endpoint) => + !only?.length || + only.some((slug) => + String(endpoint.provider_name) + .toLowerCase() + .startsWith(slug.toLowerCase()) + ) + ) + + const rated = endpoints.map((endpoint) => ({ + provider: endpoint.provider_name, + input: Number(endpoint.pricing?.prompt || 0), + output: Number(endpoint.pricing?.completion || 0), + })) + + const input = Math.max(0, ...rated.map((item) => item.input)) + const output = Math.max(0, ...rated.map((item) => item.output)) + + const providersAt = (side, value) => + rated + .filter((item) => item[side] === value) + .map((item) => item.provider) + .join('|') + + const contextLengths = endpoints + .map((endpoint) => endpoint.context_length) + .filter((value) => typeof value === 'number') + const maxCompletionTokens = endpoints + .map((endpoint) => endpoint.max_completion_tokens) + .filter((value) => typeof value === 'number') + + return { + pricing: + input || output + ? { + input, + output, + inputProvider: providersAt('input', input), + outputProvider: providersAt('output', output), + } + : undefined, + contextLengths, + maxCompletionTokens: maxCompletionTokens.length + ? Math.max(...maxCompletionTokens) + : undefined, + } + } + it('must match configured Vercel model pricing and sizing', async () => { const response = await fetch('https://ai-gateway.vercel.sh/v1/models', { headers: { @@ -339,18 +418,28 @@ describeIfConfigured('listModels', () => { const modelsById = new Map(data.map((model) => [model.id, model])) + const languageModels = Object.entries(vercelLanguageModels).filter( + ([, config]) => !config.deprecated && config.visible + ) + + const endpointFactsByModel = new Map( + await Promise.all( + languageModels.map(async ([modelName, config]) => [ + modelName, + await getEndpointFacts(getLanguageModel({ model: modelName }), config), + ]) + ) + ) + const mismatches = [] // Models the gateway lists but for which it publishes no token pricing // (e.g. the perplexity/sonar* family return an empty `pricing` object). // Their absolute price cannot be diffed against upstream; we still check // ratio consistency and surface them so the gap is visible, not silent. const unverified = [] + const overpriced = [] - for (const [modelName, config] of Object.entries(vercelLanguageModels)) { - if (config.deprecated || !config.visible) { - continue - } - + for (const [modelName, config] of languageModels) { const modelId = getLanguageModel({ model: modelName }) const liveModel = modelsById.get(modelId) @@ -360,13 +449,29 @@ describeIfConfigured('listModels', () => { continue } - const liveInputPrice = parseVercelPrice(liveModel.pricing?.input) - const liveOutputPrice = parseVercelPrice(liveModel.pricing?.output) - const liveContextLength = liveModel.context_window - const liveMaxCompletionTokens = liveModel.max_tokens + const endpointFacts = endpointFactsByModel.get(modelName) + const worstCase = endpointFacts?.pricing + + const liveInputPrice = parseVercelPrice( + worstCase ? worstCase.input : liveModel.pricing?.input + ) + const liveOutputPrice = parseVercelPrice( + worstCase ? worstCase.output : liveModel.pricing?.output + ) + const liveContextLengths = endpointFacts?.contextLengths.length + ? endpointFacts.contextLengths + : [liveModel.context_window].filter((value) => typeof value === 'number') + // an upper bound, so the most permissive figure either source reports + const liveMaxCompletionTokens = Math.max( + ...[endpointFacts?.maxCompletionTokens, liveModel.max_tokens].filter( + (value) => typeof value === 'number' + ) + ) const hasUpstreamPricing = - liveModel.pricing?.input != null || liveModel.pricing?.output != null + Boolean(worstCase) || + liveModel.pricing?.input != null || + liveModel.pricing?.output != null if (!hasUpstreamPricing) { // The gateway lists the model but publishes no token pricing - there is @@ -380,30 +485,45 @@ describeIfConfigured('listModels', () => { `${modelName}: upstream reports zero pricing (input=${liveInputPrice}, output=${liveOutputPrice})` ) } else { - if (roundPrice(config.pricing.inputPrice || 0) !== liveInputPrice) { - mismatches.push( - `${modelName}: inputPrice ${config.pricing.inputPrice} !== ${liveInputPrice}` - ) - } - - if (roundPrice(config.pricing.outputPrice || 0) !== liveOutputPrice) { - mismatches.push( - `${modelName}: outputPrice ${config.pricing.outputPrice} !== ${liveOutputPrice}` - ) + // @note backends come and go, so the ceiling moves in both + // directions. Charging less than the ceiling under-bills and fails; + // charging more is margin left behind by a backend that went away, + // worth a look but not a defect. + const sides = [ + ['inputPrice', liveInputPrice, worstCase?.inputProvider], + ['outputPrice', liveOutputPrice, worstCase?.outputProvider], + ] + + for (const [side, livePrice, provider] of sides) { + const configuredPrice = roundPrice(config.pricing[side] || 0) + const detail = + `${modelName}: ${side} ${config.pricing[side]} ` + + `${configuredPrice < livePrice ? '<' : '>'} ${livePrice}` + + (provider ? ` (${provider})` : '') + + if (configuredPrice < livePrice) { + mismatches.push(detail) + } else if (configuredPrice > livePrice) { + overpriced.push(detail) + } } } if ( - typeof liveContextLength === 'number' && - config.maxTokens !== liveContextLength + liveContextLengths.length && + !liveContextLengths.includes(config.maxTokens) ) { mismatches.push( - `${modelName}: maxTokens ${config.maxTokens} !== ${liveContextLength}` + `${modelName}: maxTokens ${config.maxTokens} not in ${[ + ...new Set(liveContextLengths), + ] + .sort((a, b) => a - b) + .join('|')}` ) } if ( - typeof liveMaxCompletionTokens === 'number' && + Number.isFinite(liveMaxCompletionTokens) && config.maxOutputTokens > liveMaxCompletionTokens ) { mismatches.push( @@ -412,12 +532,12 @@ describeIfConfigured('listModels', () => { } if ( - typeof liveContextLength === 'number' && - config.maxInputTokens > liveContextLength - config.maxOutputTokens + liveContextLengths.length && + config.maxInputTokens > config.maxTokens - config.maxOutputTokens ) { mismatches.push( `${modelName}: maxInputTokens ${config.maxInputTokens} > ${ - liveContextLength - config.maxOutputTokens + config.maxTokens - config.maxOutputTokens }` ) } @@ -721,6 +841,14 @@ describeIfConfigured('listModels', () => { } } + if (overpriced.length) { + // eslint-disable-next-line no-console + console.warn( + `[vercel-catalogue] ${overpriced.length} model(s) priced above the ` + + `most expensive reachable backend: ${overpriced.join('; ')}` + ) + } + if (unverified.length) { // eslint-disable-next-line no-console console.warn( @@ -739,18 +867,7 @@ describeIfConfigured('listModels', () => { // longer matches anything means upstream fixed their catalogue - the test // then fails with a "stale exception" line so the entry is removed rather // than lingering. To extend, re-verify upstream first, then bump `expires`. - const upstreamMismatchExceptions = [ - { - // MiniMax documents up to 1M context, but the gateway lists 512k. - prefix: 'minimax-m3: maxTokens', - expires: '2026-10-11', - }, - { - // 1M context minus the documented 128k maximum output leaves 872k input. - prefix: 'minimax-m3: maxInputTokens', - expires: '2026-10-11', - }, - ] + const upstreamMismatchExceptions = [] const exceptionStates = upstreamMismatchExceptions.map((exception) => ({ exception, diff --git a/platform/lib/session.get.js b/platform/lib/session.get.js index c96779b..8123ad8 100644 --- a/platform/lib/session.get.js +++ b/platform/lib/session.get.js @@ -741,10 +741,21 @@ export async function getSession(req, res) { res || getContextNextApiResponse() ) - session = - req instanceof ServerActionRequest - ? await getServerSession(authOptions) - : await getServerSession(incomingReq, incomingRes, authOptions) + // @note App Router route handlers pass a Web Request and register no + // NextApiRequest in context. next-auth reads `cookies` and a plain + // `headers` object off the request it is given, and a Web Request has + // neither, so it would never find the session cookie. Those requests + // resolve through the next/headers form, the same way server actions do. + + const useHeadersStore = + req instanceof ServerActionRequest || + (!getContextNextApiRequest() && + typeof Request !== 'undefined' && + req instanceof Request) + + session = useHeadersStore + ? await getServerSession(authOptions) + : await getServerSession(incomingReq, incomingRes, authOptions) } // We check if the session is valid and if it is not we throw an error. diff --git a/platform/lib/session.get.utest.js b/platform/lib/session.get.utest.js index fa03177..7080c48 100644 --- a/platform/lib/session.get.utest.js +++ b/platform/lib/session.get.utest.js @@ -1667,6 +1667,27 @@ describe('Session resolution (next-auth session)', () => { ) }) + it('resolves an App Router route handler Request through next/headers', async () => { + // @note a Web Request without a NextApiRequest in context is what App + // Router route handlers pass; next-auth cannot read cookies off it + const req = new Request('http://localhost/apps/test/api/op', { + method: 'POST', + headers: { + 'x-requested-with': 'XMLHttpRequest', + cookie: 'next-auth.session-token=anything', + }, + }) + + hasProtection.mockReturnValue(true) + getServerSession.mockResolvedValue(mockValidSession) + + const session = await getSession(req) + + expect(session.user.email).toBe('test@test.com') + expect(getServerSession).toHaveBeenCalledTimes(1) + expect(getServerSession).toHaveBeenCalledWith(authOptions) + }) + it('never makes an outbound request to resolve a cookie session', async () => { const fetchSpy = jest.spyOn(global, 'fetch') diff --git a/platform/next.config.d/hmr.config.js b/platform/next.config.d/hmr.config.js new file mode 100644 index 0000000..b978a53 --- /dev/null +++ b/platform/next.config.d/hmr.config.js @@ -0,0 +1,16 @@ +// @ts-check + +/** @type {import('next').NextConfig} */ +export default { + experimental: { + // @note dev only. The Server Components HMR cache replays every fetch a + // render made, keyed by URL and body, when the page refreshes after a code + // change - `cache: 'no-store'` does not opt a call out. The PlanetScale + // driver talks to the database over fetch and its `BEGIN` request has the + // same body every time, so a refresh handed the driver the session of a + // transaction that had already committed and the next statement failed + // with `transaction ... ended (transaction committed)`. Stateful HTTP + // protocols cannot be replayed, so the cache stays off. + serverComponentsHmrCache: false, + }, +} diff --git a/platform/next.config.d/hmr.config.utest.js b/platform/next.config.d/hmr.config.utest.js new file mode 100644 index 0000000..7105c19 --- /dev/null +++ b/platform/next.config.d/hmr.config.utest.js @@ -0,0 +1,9 @@ +import config from './hmr.config' + +describe('hmr.config', () => { + it('keeps the Server Components HMR fetch cache off', () => { + // @note the database driver speaks a stateful protocol over fetch; a + // replayed `BEGIN` binds a request to an already committed transaction + expect(config.experimental.serverComponentsHmrCache).toBe(false) + }) +})