diff --git a/packages/app/cypress/e2e/embed-scatter.cy.ts b/packages/app/cypress/e2e/embed-scatter.cy.ts
new file mode 100644
index 00000000..5db0a2a8
--- /dev/null
+++ b/packages/app/cypress/e2e/embed-scatter.cy.ts
@@ -0,0 +1,54 @@
+describe('Embed — Scatter Chart', () => {
+ describe('default URL', () => {
+ before(() => {
+ cy.visit('/embed/scatter');
+ });
+
+ it('renders the embed root container', () => {
+ cy.get('[data-testid="embed-root"]').should('exist');
+ });
+
+ it('does not render the site header or footer', () => {
+ cy.get('[data-testid="header"]').should('not.exist');
+ cy.get('[data-testid="footer"]').should('not.exist');
+ });
+
+ it('renders an SVG chart with real data', () => {
+ // Wait for chart to render — skeleton or figure
+ cy.get('[data-testid="embed-scatter-figure"]', { timeout: 15000 }).should('exist');
+ cy.get('[data-testid="embed-scatter-figure"]').find('svg').should('exist');
+ cy.contains('No data available').should('not.exist');
+ });
+
+ it('shows the SemiAnalysis InferenceX attribution link', () => {
+ cy.get('[data-testid="embed-attribution"]')
+ .should('exist')
+ .should('contain.text', 'SemiAnalysis InferenceX');
+ });
+
+ it('attribution link points to the canonical /inference URL with seeded params', () => {
+ cy.get('[data-testid="embed-attribution"]')
+ .should('have.attr', 'href')
+ .and('include', '/inference?')
+ .and('include', 'g_model=DeepSeek-R1-0528')
+ .and('include', 'i_metric=y_tpPerGpu');
+ });
+ });
+
+ describe('custom params', () => {
+ before(() => {
+ cy.visit('/embed/scatter?model=dsr1&isl=8192&osl=1024&precisions=fp4&y=costh');
+ });
+
+ it('renders chart with the custom y metric', () => {
+ cy.get('[data-testid="embed-scatter-figure"]', { timeout: 15000 }).should('exist');
+ cy.contains('No data available').should('not.exist');
+ });
+
+ it('canonical link reflects the y metric override', () => {
+ cy.get('[data-testid="embed-attribution"]')
+ .should('have.attr', 'href')
+ .and('include', 'i_metric=y_costh');
+ });
+ });
+});
diff --git a/packages/app/next.config.ts b/packages/app/next.config.ts
index 39ab4487..69d6972a 100644
--- a/packages/app/next.config.ts
+++ b/packages/app/next.config.ts
@@ -15,6 +15,27 @@ const nextConfig: NextConfig = {
{ hostname: 'substack-post-media.s3.amazonaws.com' },
],
},
+ // /embed/* routes are explicitly framable from any origin so partner sites
+ // can iframe them. All other routes are non-framable (frame-ancestors
+ // 'self' + X-Frame-Options: SAMEORIGIN). Order matters: the more specific
+ // /embed/* match comes first.
+ headers() {
+ return Promise.resolve([
+ {
+ source: '/embed/:path*',
+ headers: [{ key: 'Content-Security-Policy', value: 'frame-ancestors *' }],
+ },
+ {
+ // Negative lookahead excludes /embed/* so its CSP isn't overridden
+ // by the more general non-framable headers.
+ source: '/((?!embed/).*)',
+ headers: [
+ { key: 'Content-Security-Policy', value: "frame-ancestors 'self'" },
+ { key: 'X-Frame-Options', value: 'SAMEORIGIN' },
+ ],
+ },
+ ]);
+ },
};
const hasPostHogKeys = Boolean(
diff --git a/packages/app/src/app/embed/layout.tsx b/packages/app/src/app/embed/layout.tsx
new file mode 100644
index 00000000..169b3878
--- /dev/null
+++ b/packages/app/src/app/embed/layout.tsx
@@ -0,0 +1,16 @@
+import type { Metadata } from 'next';
+
+export const metadata: Metadata = {
+ robots: { index: false, follow: false },
+};
+
+export default function EmbedLayout({ children }: { children: React.ReactNode }) {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/packages/app/src/app/embed/scatter/embed-scatter-client.tsx b/packages/app/src/app/embed/scatter/embed-scatter-client.tsx
new file mode 100644
index 00000000..83426f2c
--- /dev/null
+++ b/packages/app/src/app/embed/scatter/embed-scatter-client.tsx
@@ -0,0 +1,81 @@
+'use client';
+
+import { useEffect, useRef } from 'react';
+
+import EmbedScatterDisplay from '@/components/embed/embed-scatter-display';
+import { EmbedAttribution } from '@/components/embed/embed-attribution';
+import { GlobalFilterProvider } from '@/components/GlobalFilterContext';
+import { InferenceProvider } from '@/components/inference/InferenceContext';
+import { UnofficialRunProvider } from '@/components/unofficial-run-provider';
+import { track } from '@/lib/analytics';
+import { type EmbedParams, embedParamsToUrlState } from '@/lib/embed-params';
+import { seedUrlState } from '@/lib/url-state';
+
+interface Props {
+ params: EmbedParams;
+ canonicalHref: string;
+}
+
+/**
+ * Client component for `/embed/scatter`. Seeds the internal URL-state cache
+ * synchronously before any provider mounts so the first render of the chart
+ * already reflects the requested embed params, then wraps the providers and
+ * the chart display.
+ *
+ * Lives outside the `(dashboard)` route group, so we re-establish the
+ * provider stack here (`UnofficialRunProvider` → `GlobalFilterProvider` →
+ * `InferenceProvider`). `QueryProvider` is in the root layout and inherits.
+ */
+export default function EmbedScatterClient({ params, canonicalHref }: Props) {
+ const seededRef = useRef(false);
+ if (!seededRef.current) {
+ seedUrlState(embedParamsToUrlState(params));
+ seededRef.current = true;
+ }
+
+ // Fire `embed_view` once on mount with referrer + host so external embed
+ // traffic is attributable. Strict mode in dev double-fires effects, but
+ // that's only in dev — production fires once.
+ const trackedRef = useRef(false);
+ useEffect(() => {
+ if (trackedRef.current) return;
+ trackedRef.current = true;
+ const referrer = typeof document !== 'undefined' ? document.referrer : '';
+ let embedHost: string;
+ try {
+ embedHost = referrer ? new URL(referrer).host : '';
+ } catch {
+ embedHost = '';
+ }
+ const gpus = params.gpus ? params.gpus.split(',').filter(Boolean) : [];
+ track('embed_view', {
+ embed_chart: 'scatter',
+ chart_type: params.chart,
+ model: params.model,
+ sequence: `${params.isl}/${params.osl}`,
+ precisions: params.precisions,
+ gpus,
+ gpu_count: gpus.length,
+ y_metric: params.y,
+ referrer,
+ embed_host: embedHost,
+ });
+ }, []);
+
+ return (
+
+
+
+
+
+
+
+ );
+}
diff --git a/packages/app/src/app/embed/scatter/page.tsx b/packages/app/src/app/embed/scatter/page.tsx
new file mode 100644
index 00000000..df7e14cd
--- /dev/null
+++ b/packages/app/src/app/embed/scatter/page.tsx
@@ -0,0 +1,26 @@
+import type { Metadata } from 'next';
+
+import { SITE_URL } from '@semianalysisai/inferencex-constants';
+import { buildCanonicalHref, readEmbedParams } from '@/lib/embed-params';
+
+import EmbedScatterClient from './embed-scatter-client';
+
+export const metadata: Metadata = {
+ title: 'InferenceX — Embedded Chart',
+ robots: { index: false, follow: false },
+};
+
+export default async function EmbedScatterPage({
+ searchParams,
+}: {
+ searchParams: Promise>;
+}) {
+ const sp = await searchParams;
+ const flat: Record = {};
+ for (const [k, v] of Object.entries(sp)) {
+ flat[k] = Array.isArray(v) ? v[0] : v;
+ }
+ const params = readEmbedParams(flat);
+ const canonicalHref = buildCanonicalHref(params, SITE_URL);
+ return ;
+}
diff --git a/packages/app/src/app/layout.tsx b/packages/app/src/app/layout.tsx
index 93088655..25449ae3 100644
--- a/packages/app/src/app/layout.tsx
+++ b/packages/app/src/app/layout.tsx
@@ -7,6 +7,7 @@ import type { Metadata } from 'next';
import { DM_Sans } from 'next/font/google';
import localFont from 'next/font/local';
+import { ChromeGate } from '@/components/chrome-gate';
import { Footer } from '@/components/footer/footer';
import { Header } from '@/components/header/header';
import { CircuitBackground } from '@/components/circuit-background';
@@ -191,9 +192,13 @@ export default async function RootLayout({
disableTransitionOnChange
>
-
+
+
+
{children}
-
+
+
+
{process.env.VERCEL && }
diff --git a/packages/app/src/components/chrome-gate.tsx b/packages/app/src/components/chrome-gate.tsx
new file mode 100644
index 00000000..12e6f22b
--- /dev/null
+++ b/packages/app/src/components/chrome-gate.tsx
@@ -0,0 +1,14 @@
+'use client';
+
+import { usePathname } from 'next/navigation';
+import type { ReactNode } from 'react';
+
+/**
+ * Hides chrome (Header, Footer) on `/embed/*` routes so the chart is rendered
+ * standalone for iframe embedding. All other routes render children unchanged.
+ */
+export function ChromeGate({ children }: { children: ReactNode }) {
+ const pathname = usePathname();
+ if (pathname?.startsWith('/embed')) return null;
+ return <>{children}>;
+}
diff --git a/packages/app/src/components/embed/embed-attribution.tsx b/packages/app/src/components/embed/embed-attribution.tsx
new file mode 100644
index 00000000..24973acf
--- /dev/null
+++ b/packages/app/src/components/embed/embed-attribution.tsx
@@ -0,0 +1,22 @@
+'use client';
+
+import { track } from '@/lib/analytics';
+
+// Attribution link rendered at the bottom of every embed view. Deep-links to
+// the canonical /inference URL with the equivalent internal params so the
+// partner site's audience can click through to the full dashboard.
+export function EmbedAttribution({ canonicalHref }: { canonicalHref: string }) {
+ return (
+ track('embed_attribution_clicked', { href: canonicalHref })}
+ className="text-xs text-muted-foreground hover:text-foreground transition-colors inline-flex items-center gap-1"
+ >
+ SemiAnalysis InferenceX
+ →
+
+ );
+}
diff --git a/packages/app/src/components/embed/embed-scatter-display.tsx b/packages/app/src/components/embed/embed-scatter-display.tsx
new file mode 100644
index 00000000..4e849311
--- /dev/null
+++ b/packages/app/src/components/embed/embed-scatter-display.tsx
@@ -0,0 +1,172 @@
+'use client';
+
+import { useMemo } from 'react';
+
+import { useInference } from '@/components/inference/InferenceContext';
+import type { InferenceData, OverlayData } from '@/components/inference/types';
+import { processOverlayChartData } from '@/components/inference/utils';
+import ScatterGraph from '@/components/inference/ui/ScatterGraph';
+import { Card } from '@/components/ui/card';
+import { Skeleton } from '@/components/ui/skeleton';
+import { useUnofficialRun } from '@/components/unofficial-run-provider';
+import {
+ type Model,
+ type Precision,
+ type Sequence,
+ getModelLabel,
+ getPrecisionLabel,
+ getSequenceLabel,
+} from '@/lib/data-mappings';
+
+interface Props {
+ /** Which chart to render — `e2e` or `interactivity`. Defaults to `e2e`. */
+ chartType: 'e2e' | 'interactivity';
+}
+
+/**
+ * Slim version of `ChartDisplay` for embeds: renders only the requested
+ * scatter chart with its caption, no controls / share buttons / changelog.
+ *
+ * Reads from the same `InferenceContext` as the dashboard so chart behavior
+ * (legend, zoom, overlay rendering) stays consistent.
+ */
+export default function EmbedScatterDisplay({ chartType }: Props) {
+ const {
+ graphs,
+ loading,
+ selectedYAxisMetric,
+ selectedXAxisMetric,
+ selectedE2eXAxisMetric,
+ selectedPrecisions,
+ selectedModel,
+ selectedSequence,
+ } = useInference();
+
+ const { unofficialRunInfo, unofficialRunInfos, runIndexByUrl, getOverlayData } =
+ useUnofficialRun();
+
+ const overlayDataByChartType = useMemo(() => {
+ if (!unofficialRunInfo || !getOverlayData) {
+ return { e2e: null, interactivity: null };
+ }
+ const e2eRaw = getOverlayData(selectedModel, selectedSequence, 'e2e');
+ const interactivityRaw = getOverlayData(selectedModel, selectedSequence, 'interactivity');
+
+ const getRunForRow = (row: InferenceData) => {
+ const url = row.run_url ?? null;
+ if (!url) return undefined;
+ if (url in runIndexByUrl) {
+ const info = unofficialRunInfos[runIndexByUrl[url]];
+ return info ? { branch: info.branch, url: info.url } : undefined;
+ }
+ const idMatch = url.match(/\/runs\/(\d+)/);
+ if (idMatch && idMatch[1] in runIndexByUrl) {
+ const info = unofficialRunInfos[runIndexByUrl[idMatch[1]]];
+ return info ? { branch: info.branch, url: info.url } : undefined;
+ }
+ return undefined;
+ };
+
+ const processData = (
+ rawData: { data: InferenceData[]; hardwareConfig: any } | null,
+ ct: 'e2e' | 'interactivity',
+ ): OverlayData | null => {
+ if (!rawData || rawData.data.length === 0) return null;
+ const effectiveXMetric = ct === 'e2e' ? selectedE2eXAxisMetric : selectedXAxisMetric;
+ const processed = processOverlayChartData(
+ rawData.data,
+ ct,
+ selectedYAxisMetric,
+ effectiveXMetric,
+ );
+ if (processed.length === 0) return null;
+ return {
+ data: processed,
+ hardwareConfig: rawData.hardwareConfig,
+ label: unofficialRunInfo.branch,
+ runUrl: unofficialRunInfo.url,
+ getRunForRow,
+ };
+ };
+
+ return {
+ e2e: processData(e2eRaw, 'e2e'),
+ interactivity: processData(interactivityRaw, 'interactivity'),
+ };
+ }, [
+ unofficialRunInfo,
+ unofficialRunInfos,
+ runIndexByUrl,
+ getOverlayData,
+ selectedModel,
+ selectedSequence,
+ selectedYAxisMetric,
+ selectedXAxisMetric,
+ selectedE2eXAxisMetric,
+ ]);
+
+ const targetGraph = useMemo(
+ () => graphs.find((g) => g.chartDefinition.chartType === chartType) ?? graphs[0],
+ [graphs, chartType],
+ );
+
+ const isFirstLoad = loading && graphs.length === 0;
+
+ if (isFirstLoad || !targetGraph) {
+ return (
+
+
+
+
+
+ );
+ }
+
+ const yLabel =
+ (targetGraph.chartDefinition[
+ `${selectedYAxisMetric}_label` as keyof typeof targetGraph.chartDefinition
+ ] as string) || '';
+ const yTitle =
+ (targetGraph.chartDefinition[
+ `${selectedYAxisMetric}_title` as keyof typeof targetGraph.chartDefinition
+ ] as string) || '';
+ const heading =
+ (targetGraph.chartDefinition[
+ `${selectedYAxisMetric}_heading` as keyof typeof targetGraph.chartDefinition
+ ] as string) || targetGraph.chartDefinition.heading;
+
+ const caption = (
+ <>
+
+ {yTitle} {heading}
+
+
+ {getModelLabel(targetGraph.model as Model)} •{' '}
+ {selectedPrecisions.map((prec) => getPrecisionLabel(prec as Precision)).join(', ')} •{' '}
+ {getSequenceLabel(targetGraph.sequence as Sequence)} • Source: SemiAnalysis InferenceX™
+
+ >
+ );
+
+ const overlay =
+ targetGraph.chartDefinition.chartType === 'e2e'
+ ? overlayDataByChartType.e2e
+ : overlayDataByChartType.interactivity;
+
+ return (
+
+
+
+
+
+ );
+}
diff --git a/packages/app/src/lib/embed-params.test.ts b/packages/app/src/lib/embed-params.test.ts
new file mode 100644
index 00000000..27f3495b
--- /dev/null
+++ b/packages/app/src/lib/embed-params.test.ts
@@ -0,0 +1,133 @@
+import { describe, it, expect } from 'vitest';
+
+import {
+ buildCanonicalHref,
+ embedParamsToUrlState,
+ EMBED_PARAM_DEFAULTS,
+ readEmbedParams,
+ resolveEmbedModel,
+ resolveEmbedSequence,
+ resolveEmbedYMetric,
+} from '@/lib/embed-params';
+
+describe('readEmbedParams', () => {
+ it('returns defaults for empty input', () => {
+ expect(readEmbedParams(new URLSearchParams())).toEqual(EMBED_PARAM_DEFAULTS);
+ });
+
+ it('returns defaults for null input', () => {
+ expect(readEmbedParams(null)).toEqual(EMBED_PARAM_DEFAULTS);
+ });
+
+ it('reads model, isl, osl, precisions, gpus, y', () => {
+ const sp = new URLSearchParams(
+ 'model=llama70b&isl=1024&osl=8192&precisions=fp8&gpus=h200_vllm,b200_sglang&y=costh',
+ );
+ expect(readEmbedParams(sp)).toEqual({
+ model: 'llama70b',
+ isl: '1024',
+ osl: '8192',
+ precisions: 'fp8',
+ gpus: 'h200_vllm,b200_sglang',
+ y: 'costh',
+ chart: 'e2e',
+ });
+ });
+
+ it('accepts chart=interactivity', () => {
+ const sp = new URLSearchParams('chart=interactivity');
+ expect(readEmbedParams(sp).chart).toBe('interactivity');
+ });
+
+ it('falls back to e2e for unknown chart values', () => {
+ expect(readEmbedParams(new URLSearchParams('chart=bogus')).chart).toBe('e2e');
+ });
+
+ it('reads from plain object input', () => {
+ expect(readEmbedParams({ model: 'dsv4', y: 'tpPerMw' })).toMatchObject({
+ model: 'dsv4',
+ y: 'tpPerMw',
+ });
+ });
+});
+
+describe('resolveEmbedYMetric', () => {
+ it('maps short forms to internal y_* keys', () => {
+ expect(resolveEmbedYMetric('tpPerGpu')).toBe('y_tpPerGpu');
+ expect(resolveEmbedYMetric('costh')).toBe('y_costh');
+ expect(resolveEmbedYMetric('tpPerMw')).toBe('y_tpPerMw');
+ });
+
+ it('passes through full y_* keys', () => {
+ expect(resolveEmbedYMetric('y_costnOutput')).toBe('y_costnOutput');
+ });
+
+ it('falls back to default for unknown metrics', () => {
+ expect(resolveEmbedYMetric('not_a_metric')).toBe('y_tpPerGpu');
+ expect(resolveEmbedYMetric(null)).toBe('y_tpPerGpu');
+ });
+});
+
+describe('resolveEmbedModel', () => {
+ it('maps known DB keys to display names', () => {
+ expect(resolveEmbedModel('dsr1')).toBe('DeepSeek-R1-0528');
+ expect(resolveEmbedModel('llama70b')).toBe('Llama-3.3-70B-Instruct-FP8');
+ });
+
+ it('falls back to the default model for unknown keys', () => {
+ expect(resolveEmbedModel('not-a-model')).toBe('DeepSeek-R1-0528');
+ });
+});
+
+describe('resolveEmbedSequence', () => {
+ it('maps known isl/osl pairs to sequence strings', () => {
+ expect(resolveEmbedSequence('8192', '1024')).toBe('8k/1k');
+ expect(resolveEmbedSequence('1024', '1024')).toBe('1k/1k');
+ expect(resolveEmbedSequence('1024', '8192')).toBe('1k/8k');
+ });
+
+ it('falls back to default sequence for unknown pairs', () => {
+ expect(resolveEmbedSequence('999', '999')).toBe('8k/1k');
+ expect(resolveEmbedSequence('not-a-number', '1024')).toBe('8k/1k');
+ });
+});
+
+describe('embedParamsToUrlState', () => {
+ it('translates defaults to the matching url-state shape', () => {
+ expect(embedParamsToUrlState(EMBED_PARAM_DEFAULTS)).toEqual({
+ g_model: 'DeepSeek-R1-0528',
+ i_seq: '8k/1k',
+ i_prec: 'fp4',
+ i_metric: 'y_tpPerGpu',
+ });
+ });
+
+ it('includes i_active when gpus are specified', () => {
+ const params = readEmbedParams(new URLSearchParams('gpus=b300_sglang,gb300_dynamo-sglang'));
+ expect(embedParamsToUrlState(params).i_active).toBe('b300_sglang,gb300_dynamo-sglang');
+ });
+
+ it('omits i_active when no gpus are specified', () => {
+ expect(embedParamsToUrlState(EMBED_PARAM_DEFAULTS).i_active).toBeUndefined();
+ });
+});
+
+describe('buildCanonicalHref', () => {
+ it('points to /inference and round-trips the embed state', () => {
+ const params = readEmbedParams(
+ new URLSearchParams('model=dsv4&isl=1024&osl=1024&precisions=fp4&gpus=b200_vllm&y=costh'),
+ );
+ const href = buildCanonicalHref(params, 'https://inferencex.semianalysis.com');
+ expect(href).toContain('https://inferencex.semianalysis.com/inference?');
+ expect(href).toContain('g_model=DeepSeek-V4-Pro');
+ expect(href).toContain('i_seq=1k%2F1k');
+ expect(href).toContain('i_prec=fp4');
+ expect(href).toContain('i_metric=y_costh');
+ expect(href).toContain('i_active=b200_vllm');
+ });
+
+ it('omits i_active when no gpus are specified', () => {
+ const href = buildCanonicalHref(EMBED_PARAM_DEFAULTS, 'https://example.com');
+ expect(href).not.toContain('i_active');
+ });
+});
diff --git a/packages/app/src/lib/embed-params.ts b/packages/app/src/lib/embed-params.ts
new file mode 100644
index 00000000..4b32ef0d
--- /dev/null
+++ b/packages/app/src/lib/embed-params.ts
@@ -0,0 +1,161 @@
+/**
+ * @file embed-params.ts
+ * @description Stable, public-contract URL parameter shape for `/embed/*` routes.
+ *
+ * The internal app uses `g_*` / `i_*` / `e_*` keys (see `url-state.ts`) which we
+ * reserve the right to refactor. The embed surface uses a different, smaller set
+ * of keys we commit to keeping working long-term so partner sites can iframe a
+ * stable URL contract:
+ *
+ * /embed/scatter?model=dsr1&isl=8192&osl=1024&precisions=fp4&gpus=b300_sglang,gb300_dynamo-sglang&y=tpPerGpu&chart=e2e
+ *
+ * Translation is one-way: embed-shaped params → internal `UrlStateParams`. The
+ * translator runs synchronously before provider initializers fire (via
+ * `seedUrlState`) so the chart mounts already pointed at the requested state.
+ */
+
+import { DB_MODEL_TO_DISPLAY, islOslToSequence } from '@semianalysisai/inferencex-constants';
+import type { UrlStateParams } from '@/lib/url-state';
+
+/**
+ * Default values for embed params. Defaults match the inference page's defaults
+ * so a bare `/embed/scatter` URL renders the same default chart users see at
+ * `/inference`.
+ */
+export const EMBED_PARAM_DEFAULTS = {
+ model: 'dsr1',
+ isl: '8192',
+ osl: '1024',
+ precisions: 'fp4',
+ gpus: '',
+ y: 'tpPerGpu',
+ chart: 'e2e' as 'e2e' | 'interactivity',
+};
+
+export type EmbedParams = typeof EMBED_PARAM_DEFAULTS;
+
+/**
+ * Y-axis metric short forms accepted in the embed URL contract. Maps to the
+ * internal `y_*` keys used by the chart config. Both the short form (`tpPerGpu`)
+ * and the full form (`y_tpPerGpu`) are accepted on input — full form is mainly
+ * for forward-compat if we add metrics whose short alias hasn't been picked.
+ */
+const Y_METRIC_ALIASES: Record = {
+ tpPerGpu: 'y_tpPerGpu',
+ inputTputPerGpu: 'y_inputTputPerGpu',
+ outputTputPerGpu: 'y_outputTputPerGpu',
+ tpPerMw: 'y_tpPerMw',
+ inputTputPerMw: 'y_inputTputPerMw',
+ outputTputPerMw: 'y_outputTputPerMw',
+ costh: 'y_costh',
+ costn: 'y_costn',
+ costr: 'y_costr',
+ costhOutput: 'y_costhOutput',
+ costnOutput: 'y_costnOutput',
+ costrOutput: 'y_costrOutput',
+ costhi: 'y_costhi',
+ costni: 'y_costni',
+ costri: 'y_costri',
+ jTotal: 'y_jTotal',
+ jOutput: 'y_jOutput',
+ jInput: 'y_jInput',
+};
+
+/**
+ * Translate an embed `y` param (short or full form) to the internal `y_*` key.
+ * Unknown values fall back to the default.
+ */
+export function resolveEmbedYMetric(value: string | null | undefined): string {
+ if (!value) return Y_METRIC_ALIASES[EMBED_PARAM_DEFAULTS.y]!;
+ if (value.startsWith('y_')) return value;
+ return Y_METRIC_ALIASES[value] ?? Y_METRIC_ALIASES[EMBED_PARAM_DEFAULTS.y]!;
+}
+
+/**
+ * Read embed params from a URLSearchParams-compatible source, applying defaults
+ * for missing values. Always returns a fully populated object so callers don't
+ * have to handle nullables.
+ */
+export function readEmbedParams(
+ source: URLSearchParams | Record | null | undefined,
+): EmbedParams {
+ const get = (k: string): string | undefined => {
+ if (!source) return undefined;
+ if (source instanceof URLSearchParams) return source.get(k) ?? undefined;
+ return source[k];
+ };
+
+ const chartRaw = get('chart');
+ const chart: 'e2e' | 'interactivity' = chartRaw === 'interactivity' ? 'interactivity' : 'e2e';
+
+ return {
+ model: get('model') || EMBED_PARAM_DEFAULTS.model,
+ isl: get('isl') || EMBED_PARAM_DEFAULTS.isl,
+ osl: get('osl') || EMBED_PARAM_DEFAULTS.osl,
+ precisions: get('precisions') || EMBED_PARAM_DEFAULTS.precisions,
+ gpus: get('gpus') ?? EMBED_PARAM_DEFAULTS.gpus,
+ y: get('y') || EMBED_PARAM_DEFAULTS.y,
+ chart,
+ };
+}
+
+/**
+ * Translate a DB model key (`dsr1`) to the display name (`DeepSeek-R1-0528`)
+ * used internally as the `g_model` value. Falls back to the default model when
+ * the key is unknown — partner sites with stale model keys still render
+ * something instead of an empty page.
+ */
+export function resolveEmbedModel(dbKey: string): string {
+ return (
+ DB_MODEL_TO_DISPLAY[dbKey] ??
+ DB_MODEL_TO_DISPLAY[EMBED_PARAM_DEFAULTS.model] ??
+ 'DeepSeek-R1-0528'
+ );
+}
+
+/**
+ * Translate an `isl`/`osl` pair into the internal sequence string (`8k/1k` etc).
+ * Falls back to the default sequence when the pair has no known mapping.
+ */
+export function resolveEmbedSequence(isl: string, osl: string): string {
+ const islN = Number.parseInt(isl, 10);
+ const oslN = Number.parseInt(osl, 10);
+ if (Number.isFinite(islN) && Number.isFinite(oslN)) {
+ const seq = islOslToSequence(islN, oslN);
+ if (seq) return seq;
+ }
+ return '8k/1k';
+}
+
+/**
+ * Translate embed params into the internal `UrlStateParams` shape that the
+ * inference providers consume on mount. Pass the result to `seedUrlState`
+ * before any provider mounts.
+ */
+export function embedParamsToUrlState(params: EmbedParams): UrlStateParams {
+ const out: UrlStateParams = {
+ g_model: resolveEmbedModel(params.model),
+ i_seq: resolveEmbedSequence(params.isl, params.osl),
+ i_prec: params.precisions,
+ i_metric: resolveEmbedYMetric(params.y),
+ };
+ if (params.gpus) {
+ out.i_active = params.gpus;
+ }
+ return out;
+}
+
+/**
+ * Build the canonical, internal-route URL that an embed view's attribution
+ * link should deep-link to. Mirrors the embed state into the dashboard's
+ * `g_*` / `i_*` keys so opening the canonical link reproduces the same chart.
+ */
+export function buildCanonicalHref(params: EmbedParams, origin: string): string {
+ const sp = new URLSearchParams();
+ sp.set('g_model', resolveEmbedModel(params.model));
+ sp.set('i_seq', resolveEmbedSequence(params.isl, params.osl));
+ sp.set('i_prec', params.precisions);
+ sp.set('i_metric', resolveEmbedYMetric(params.y));
+ if (params.gpus) sp.set('i_active', params.gpus);
+ return `${origin}/inference?${sp.toString()}`;
+}
diff --git a/packages/app/src/lib/url-state.ts b/packages/app/src/lib/url-state.ts
index 58682ec1..d10084d7 100644
--- a/packages/app/src/lib/url-state.ts
+++ b/packages/app/src/lib/url-state.ts
@@ -135,6 +135,27 @@ export function readUrlParams(): UrlStateParams {
return _initialParams;
}
+/**
+ * Synchronously seed the URL-state cache before any provider's lazy
+ * `useState(() => getUrlParam(...))` initializer fires. Used by the embed
+ * routes to translate their stable, public-contract param shape (e.g.
+ * `?model=dsr1&y=tpPerGpu&gpus=...`) into the internal `g_*` / `i_*` keys.
+ *
+ * Must be called from a top-level module-scope statement in a client
+ * component (or its server-rendered parent) so the writes happen before
+ * `InferenceProvider` mounts. Calling it after a provider initializes its
+ * state has no effect on that provider — the lazy initializer has already
+ * run by then.
+ */
+export function seedUrlState(params: UrlStateParams): void {
+ for (const [key, value] of Object.entries(params)) {
+ const urlKey = key as UrlStateKey;
+ if (value === undefined) continue;
+ _initialParams[urlKey] = value;
+ currentState[urlKey] = value;
+ }
+}
+
/** Check whether the current URL has any share-link params. */
export function hasAnyUrlParams(): boolean {
if (typeof window === 'undefined') return false;