ソースを参照

feat: add model testing pages

Thomas Zhang 3 ヶ月 前
コミット
bdd143c93e

+ 91 - 3
src/lib/api/models.ts

@@ -1,10 +1,40 @@
 import { resolve } from '$app/paths';
 
+import { handleResponse, type TestResponse } from '$lib/api/types';
 import type { ManagedModelCR } from '$lib/types/custom-resources';
-import type { Model } from '$lib/types/entities';
+import type { Model, ModelInferenceParameters } from '$lib/types/entities';
 import type { Changes, CoreResources } from '$lib/types/workspace';
 
-import { handleResponse } from './types';
+type ChatMessage = { role: 'system' | 'user' | 'assistant'; content: string };
+
+type ChatCompletionRequest = {
+	messages: ChatMessage[];
+	parameters?: ModelInferenceParameters;
+};
+
+type EmbeddingRequest = string;
+
+type EmbeddingResponse = TestResponse<{
+	model: string;
+	data: { embedding: number[] }[];
+	usage: { prompt_tokens: number; total_tokens: number };
+}>;
+
+type RerankRequest = {
+	query: string;
+	documents: string[];
+};
+
+type RankedItem = { index: number; relevance_score: number };
+type RerankResponse = TestResponse<{
+	model: string;
+	meta?: {
+		billed_units: { total_tokens: number };
+		tokens: { input_tokens: number };
+	};
+	usage?: { prompt_tokens: number; total_tokens: number };
+	results: RankedItem[];
+}>;
 
 const list = (workspaceId: string, fetch = globalThis.fetch): Promise<Model[]> =>
 	fetch(
@@ -99,4 +129,62 @@ const remove = (workspaceId: string, id: string, fetch = globalThis.fetch): Prom
 		if (!res.ok) await handleResponse<void>(res);
 	});
 
-export { create, get, getChanges, getResources, getStatus, list, remove, update };
+const testChatCompletion = (
+	workspaceId: string,
+	id: string,
+	request: ChatCompletionRequest,
+	signal: AbortSignal,
+	fetch = globalThis.fetch,
+): Promise<Response> =>
+	fetch(resolve('/api/workspaces/[workspaceId]/models/[id]/test', { workspaceId, id }), {
+		method: 'POST',
+		headers: { 'Content-Type': 'application/json' },
+		body: JSON.stringify(request),
+		signal,
+	});
+
+const testEmbedding = (
+	workspaceId: string,
+	id: string,
+	request: EmbeddingRequest,
+	fetch = globalThis.fetch,
+): Promise<EmbeddingResponse> =>
+	fetch(resolve('/api/workspaces/[workspaceId]/models/[id]/test', { workspaceId, id }), {
+		method: 'POST',
+		headers: { 'Content-Type': 'application/json' },
+		body: JSON.stringify(request),
+	}).then(handleResponse<EmbeddingResponse>);
+
+const testRerank = (
+	workspaceId: string,
+	id: string,
+	request: RerankRequest,
+	fetch = globalThis.fetch,
+): Promise<RerankResponse> =>
+	fetch(resolve('/api/workspaces/[workspaceId]/models/[id]/test', { workspaceId, id }), {
+		method: 'POST',
+		headers: { 'Content-Type': 'application/json' },
+		body: JSON.stringify(request),
+	}).then(handleResponse<RerankResponse>);
+
+export type {
+	ChatCompletionRequest,
+	ChatMessage,
+	EmbeddingRequest,
+	EmbeddingResponse,
+	RerankRequest,
+	RerankResponse,
+};
+export {
+	create,
+	get,
+	getChanges,
+	getResources,
+	getStatus,
+	list,
+	remove,
+	testChatCompletion,
+	testEmbedding,
+	testRerank,
+	update,
+};

+ 260 - 0
src/lib/components/models/testing/chat.svelte

@@ -0,0 +1,260 @@
+<script lang="ts">
+	import { MessageSquareOff, Send, Square } from '@lucide/svelte';
+
+	import * as modelsApi from '$lib/api/models';
+	import { GlassPane } from '$lib/components/common/glass-pane';
+	import { Button } from '$lib/components/controls/button';
+	import * as Field from '$lib/components/controls/field';
+	import { Input } from '$lib/components/controls/input';
+	import { Textarea } from '$lib/components/controls/textarea';
+	import { Optional } from '$lib/components/forms/optional';
+	import type { Model } from '$lib/types/entities';
+
+	let { workspaceId, model }: { workspaceId: string; model: Model } = $props();
+
+	let systemPrompt = $state('');
+	let messages = $state<modelsApi.ChatMessage[]>([]);
+	let inputText = $state('');
+	let chatLoading = $state(false);
+	let chatError = $state<string | null>(null);
+	let chatAbortController = $state<AbortController | null>(null);
+	let temperature = $state(0.7);
+	let maxTokens = $state(512);
+	let topP = $state(0.9);
+	let threadEndEl = $state<HTMLDivElement | undefined>(undefined);
+
+	$effect(() => {
+		messages = [];
+		chatError = null;
+		if (model && model.mode === 'external') {
+			const p = model.defaultInferenceParams;
+			temperature = p?.temperature ? parseFloat(p.temperature) : 0.7;
+			maxTokens = p?.maxTokens ?? 512;
+			topP = p?.topP ? parseFloat(p.topP) : 0.9;
+		}
+	});
+
+	$effect(() => {
+		// Access every message's content so this re-runs during streaming.
+		for (const m of messages) void m.content;
+		threadEndEl?.scrollIntoView({ block: 'nearest' });
+	});
+
+	const sendChat = async () => {
+		if (!model || !inputText.trim() || chatLoading) return;
+
+		const userText = inputText.trim();
+		inputText = '';
+		chatError = null;
+
+		const outMessages: modelsApi.ChatMessage[] = [
+			...(systemPrompt.trim() ? [{ role: 'system' as const, content: systemPrompt.trim() }] : []),
+			...messages,
+			{ role: 'user', content: userText },
+		];
+
+		messages.push({ role: 'user', content: userText });
+		const assistantIdx = messages.length;
+		messages.push({ role: 'assistant', content: '' });
+
+		chatLoading = true;
+		const ctrl = new AbortController();
+		chatAbortController = ctrl;
+
+		try {
+			const res = await modelsApi.testChatCompletion(
+				workspaceId,
+				model.id,
+				{
+					messages: outMessages,
+					parameters: { temperature: `${temperature}`, maxTokens, topP: `${topP}` },
+				},
+				ctrl.signal,
+			);
+
+			if (!res.ok) {
+				const errBody = (await res.json().catch(() => ({ message: res.statusText }))) as {
+					message?: string;
+				};
+				throw new Error(errBody.message ?? res.statusText);
+			}
+
+			const reader = res.body!.getReader();
+			const decoder = new TextDecoder();
+			let buf = '';
+
+			for (;;) {
+				const { done, value } = await reader.read();
+				if (done) break;
+				buf += decoder.decode(value, { stream: true });
+				const lines = buf.split('\n');
+				buf = lines.pop() ?? '';
+
+				for (const line of lines) {
+					if (!line.startsWith('data: ')) continue;
+					const chunk = line.slice(6).trim();
+					if (chunk === '[DONE]') continue;
+					try {
+						const parsed = JSON.parse(chunk) as {
+							choices?: [{ delta?: { content?: string } }];
+						};
+						const delta = parsed.choices?.[0]?.delta?.content ?? '';
+						if (delta) messages[assistantIdx].content += delta;
+					} catch {
+						// skip malformed chunk
+					}
+				}
+			}
+		} catch (err) {
+			if (err instanceof Error && err.name === 'AbortError') return;
+			messages.splice(assistantIdx, 1);
+			chatError = err instanceof Error ? err.message : 'An error occurred';
+		} finally {
+			chatLoading = false;
+			chatAbortController = null;
+		}
+	};
+
+	const stopChat = () => {
+		chatAbortController?.abort();
+	};
+
+	const clearSession = () => {
+		messages = [];
+		systemPrompt = '';
+		chatError = null;
+		if (model && model.mode === 'external') {
+			const p = model.defaultInferenceParams;
+			temperature = p?.temperature ? parseFloat(p.temperature) : 0.7;
+			maxTokens = p?.maxTokens ?? 512;
+			topP = p?.topP ? parseFloat(p.topP) : 0.9;
+		}
+	};
+
+	const onChatKeydown = (e: KeyboardEvent) => {
+		if (e.key === 'Enter' && !e.shiftKey) {
+			e.preventDefault();
+			sendChat();
+		}
+	};
+</script>
+
+<GlassPane class="overflow-hidden bg-black/25">
+	<div class="bg-white/5 p-4">
+		<div class="mb-4 flex items-center justify-between">
+			<p class="text-lg font-thin">Chat</p>
+			<Button
+				class="text-white/50"
+				size="sm"
+				title="Clear session"
+				variant="ghost"
+				onclick={() => clearSession()}
+			>
+				<MessageSquareOff size={18} strokeWidth={2} />
+			</Button>
+		</div>
+
+		<Optional class="mb-4" title="System Prompt">
+			<Textarea
+				class="text-sm"
+				placeholder="You are a helpful assistant…"
+				rows={3}
+				bind:value={systemPrompt}
+			/>
+		</Optional>
+
+		<Optional title="Parameters">
+			<div class="grid grid-cols-3 gap-2">
+				<Field.Field>
+					<Field.Label for="maxTokens">Max Tokens</Field.Label>
+					<Input id="maxTokens" max={32768} min={1} step={1} type="number" bind:value={maxTokens} />
+				</Field.Field>
+				<Field.Field>
+					<Field.Label for="temperature">Temperature</Field.Label>
+					<Input
+						id="temperature"
+						max={2}
+						min={0}
+						step={0.05}
+						type="number"
+						bind:value={temperature}
+					/>
+				</Field.Field>
+				<Field.Field>
+					<Field.Label for="topP">Top-P</Field.Label>
+					<Input id="topP" max={1} min={0} step={0.05} type="number" bind:value={topP} />
+				</Field.Field>
+			</div>
+		</Optional>
+	</div>
+
+	<!-- Message thread -->
+	<div class="h-96 overflow-y-auto rounded-lg p-4">
+		{#each messages as msg, i (i)}
+			{#if msg.role === 'user'}
+				<div class="mb-3 flex justify-end">
+					<div
+						class="max-w-[80%] rounded-2xl rounded-tr-sm bg-primary/40 px-4 py-2 text-sm text-white/90"
+					>
+						{msg.content}
+					</div>
+				</div>
+			{:else if msg.role === 'assistant'}
+				<div class="mb-3 flex justify-start">
+					<div
+						class="max-w-[80%] rounded-2xl rounded-tl-sm border border-white/10 bg-white/5 px-4 py-2 text-sm text-white/80"
+					>
+						{#if msg.content}
+							<pre class="font-sans whitespace-pre-wrap">{msg.content}</pre>
+						{:else}
+							<span class="animate-pulse text-white/30">▋</span>
+						{/if}
+					</div>
+				</div>
+			{/if}
+		{:else}
+			<div class="flex h-full items-center justify-center">
+				<p class="text-sm text-white/50">Send a message to start the conversation.</p>
+			</div>
+		{/each}
+
+		{#if chatError}
+			<div class="mt-2 rounded-lg border border-red-500/30 bg-red-500/10 p-3 text-sm text-red-300">
+				{chatError}
+			</div>
+		{/if}
+
+		<div bind:this={threadEndEl}></div>
+	</div>
+
+	<div class="shrink-0 bg-white/5 px-4 pt-4 pb-2">
+		<Textarea
+			class="max-h-40 min-h-16 border-white/10 bg-white/5 pr-10 text-sm text-white/90 placeholder:text-white/30"
+			rows={2}
+			bind:value={inputText}
+			onkeydown={(e) => onChatKeydown(e)}
+		/>
+		<div class="mt-2 flex items-start justify-between">
+			<p class="truncate text-xs text-white/30">Enter to send, Shift+Enter for newline.</p>
+			<Button
+				disabled={inputText.trim().length === 0}
+				size="icon"
+				title={chatLoading ? 'Stop' : 'Send'}
+				variant="ghost"
+				onclick={() => {
+					if (chatLoading) {
+						stopChat();
+					} else {
+						sendChat();
+					}
+				}}
+			>
+				{#if chatLoading}
+					<Square size={18} strokeWidth={2} />
+				{:else}
+					<Send class="text-primary" size={18} strokeWidth={2} />
+				{/if}
+			</Button>
+		</div>
+	</div>
+</GlassPane>

+ 91 - 0
src/lib/components/models/testing/embedding.svelte

@@ -0,0 +1,91 @@
+<script lang="ts">
+	import { CircleCheckBig } from '@lucide/svelte';
+
+	import * as modelsApi from '$lib/api/models';
+	import { GlassPane } from '$lib/components/common/glass-pane';
+	import { TestResult } from '$lib/components/common/test-result';
+	import { Button } from '$lib/components/controls/button';
+	import type { Model } from '$lib/types/entities';
+
+	let { workspaceId, model }: { workspaceId: string; model: Model } = $props();
+
+	let sample = $state('');
+	let response = $state<modelsApi.EmbeddingResponse | undefined>(undefined);
+	let loading = $state(false);
+	let error = $state<{ type: string; message: string } | undefined>(undefined);
+	const embeddingResponse = $derived(response?.response);
+
+	$effect(() => {
+		if (model) {
+			response = undefined;
+			error = undefined;
+		}
+	});
+
+	const embed = async () => {
+		if (!model || !sample.trim() || loading) return;
+		error = undefined;
+		response = undefined;
+		loading = true;
+		try {
+			response = await modelsApi.testEmbedding(workspaceId, model.id, sample.trim());
+		} catch (err) {
+			error =
+				err instanceof Error
+					? { type: 'Request Error', message: err.message }
+					: { type: 'Unknown Error', message: 'An error occurred' };
+		} finally {
+			loading = false;
+		}
+	};
+</script>
+
+<GlassPane class="overflow-hidden bg-black/25">
+	<div class="bg-white/5 p-4">
+		<div class="flex items-center justify-between">
+			<span class="text-lg font-thin">Embeddings</span>
+			<Button disabled={!sample.trim()} title="Embed" onclick={() => embed()}>Embed</Button>
+		</div>
+	</div>
+
+	<textarea
+		class="h-full w-full resize-none p-4 font-mono text-sm outline-none"
+		placeholder="Enter sample text to embed..."
+		rows={5}
+		bind:value={sample}
+	>
+	</textarea>
+
+	<div class="bg-white/5 p-4">
+		<div class="flex items-center justify-between">
+			<span class="text-lg font-thin">Result</span>
+			{#if response}
+				<div class="flex items-center gap-2 text-sm">
+					<CircleCheckBig class="text-green-500" size={14} />
+					{#if response.latencyMs}
+						<span class="text-white/25">Latency:</span>
+						<span class="text-white/75">{response.latencyMs}ms</span>
+					{/if}
+					{#if embeddingResponse}
+						<span class="text-white/25">Dimensions:</span>
+						<span class="text-white/75">{embeddingResponse.data[0].embedding.length}</span>
+						<span class="text-white/25">Prompt tokens:</span>
+						<span class="text-white/75">{embeddingResponse.usage.prompt_tokens}</span>
+						<span class="text-white/25">Total tokens:</span>
+						<span class="text-white/75">{embeddingResponse.usage.total_tokens}</span>
+					{/if}
+				</div>
+			{/if}
+		</div>
+	</div>
+
+	<TestResult class="bg-black/20" {error} {loading} {response}>
+		{#snippet result(resp)}
+			{#if resp}
+				<pre class="p-4 font-mono text-xs text-white/75">{resp.data[0].embedding
+						.map((v) => v.toString())
+						.join(',\n')}</pre>
+			{/if}
+		{/snippet}
+	</TestResult>
+</GlassPane>

+ 5 - 0
src/lib/components/models/testing/index.ts

@@ -0,0 +1,5 @@
+import ChatTest from './chat.svelte';
+import EmbeddingTest from './embedding.svelte';
+import RerankTest from './rerank.svelte';
+
+export { ChatTest, EmbeddingTest, RerankTest };

+ 156 - 0
src/lib/components/models/testing/rerank.svelte

@@ -0,0 +1,156 @@
+<script lang="ts">
+	import { CircleCheckBig, Plus, X } from '@lucide/svelte';
+
+	import * as modelsApi from '$lib/api/models';
+	import { GlassPane } from '$lib/components/common/glass-pane';
+	import { TestResult } from '$lib/components/common/test-result';
+	import { Button } from '$lib/components/controls/button';
+	import { Spinner } from '$lib/components/controls/spinner';
+	import { Textarea } from '$lib/components/controls/textarea';
+	import type { Model } from '$lib/types/entities';
+
+	type TestDoc = { id: number; text: string; score?: number };
+
+	let { workspaceId, model }: { workspaceId: string; model: Model } = $props();
+
+	let query = $state('wireless noise cancelling headphones');
+	let docId = $state(3);
+	let docs = $state<TestDoc[]>([
+		{ id: 0, text: 'Fresh apples are available at the local farmers market.' },
+		{ id: 1, text: 'This speaker delivers powerful sound for outdoor parties.' },
+		{ id: 2, text: 'These headphones offer active noise cancellation and Bluetooth connectivity.' },
+	]);
+	let response = $state<modelsApi.RerankResponse | undefined>(undefined);
+	let loading = $state(false);
+	let error = $state<{ type: string; message: string } | undefined>(undefined);
+	const rerankResponse = $derived(response?.response);
+
+	$effect(() => {
+		if (model) {
+			response = undefined;
+			error = undefined;
+		}
+	});
+
+	const runRerank = async () => {
+		if (!model || !query.trim() || loading) return;
+		if (!query.trim() || docs.filter((d) => d.text.trim()).length < 2) return;
+		error = undefined;
+		response = undefined;
+		loading = true;
+		try {
+			response = await modelsApi.testRerank(workspaceId, model.id, {
+				query: query.trim(),
+				documents: docs.map((d) => d.text.trim()).filter((d) => d),
+			});
+			for (const item of response?.response?.results || []) {
+				docs[item.index].score = item.relevance_score;
+			}
+			docs = [...docs].sort((a, b) => (b.score ?? 0) - (a.score ?? 0));
+		} catch (err) {
+			error =
+				err instanceof Error
+					? { type: 'error', message: err.message }
+					: { type: 'error', message: 'An error occurred' };
+		} finally {
+			loading = false;
+		}
+	};
+
+	const addDoc = () => {
+		docs = [...docs, { id: docId, text: '' }];
+		docId += 1;
+	};
+
+	const removeDoc = (idx: number) => {
+		if (docs.length <= 2) return;
+		docs = docs.filter((_, i) => i !== idx);
+	};
+</script>
+
+<GlassPane class="overflow-hidden bg-black/25">
+	<div class="bg-white/5 p-4">
+		<div class="flex items-center justify-between">
+			<span class="text-lg font-thin">Rerank</span>
+			<Button
+				disabled={!query.trim() || docs.filter((d) => d.text.trim()).length < 2 || loading}
+				title="Rerank"
+				onclick={runRerank}
+			>
+				{#if loading}
+					<Spinner />
+				{/if}
+				Rerank
+			</Button>
+		</div>
+	</div>
+
+	<textarea
+		class="h-full w-full resize-none p-4 font-mono text-sm outline-none"
+		placeholder="Enter query..."
+		rows={2}
+		bind:value={query}
+	>
+	</textarea>
+
+	<div class="bg-white/5 p-4">
+		<div class="flex items-center justify-between">
+			<span class="text-lg font-thin">Documents</span>
+			{#if response}
+				<div class="flex items-center gap-2 text-sm">
+					<CircleCheckBig class="text-green-500" size={14} />
+					{#if response.latencyMs}
+						<span class="text-white/25">Latency:</span>
+						<span class="text-white/75">{response.latencyMs}ms</span>
+					{/if}
+					{#if rerankResponse}
+						<span class="text-white/25">Prompt tokens:</span>
+						<span class="text-white/75">
+							{rerankResponse.meta?.tokens.input_tokens ?? rerankResponse.usage?.prompt_tokens}
+						</span>
+						<span class="text-white/25">Total tokens:</span>
+						<span class="text-white/75">
+							{rerankResponse.meta?.billed_units.total_tokens ?? rerankResponse.usage?.total_tokens}
+						</span>
+					{/if}
+				</div>
+			{/if}
+		</div>
+	</div>
+	{#each docs as doc, idx (doc.id)}
+		<div class="flex items-start gap-2 border-b border-white/5 p-2 py-4 last:border-b-0">
+			<div class="flex w-12 flex-col items-center justify-center">
+				{#if doc.score !== undefined}
+					<div class="text-sm text-white/50">#{idx + 1}</div>
+					<div class="line-clamp-3 text-center font-mono text-xs break-all">
+						{doc.score.toFixed(10)}
+					</div>
+				{/if}
+			</div>
+			<Textarea
+				class="flex-1 resize-none text-sm"
+				placeholder="Paste document here..."
+				rows={2}
+				bind:value={doc.text}
+			/>
+			<button
+				class="mt-1 shrink-0 opacity-25 hover:opacity-75 disabled:cursor-not-allowed disabled:opacity-20"
+				disabled={docs.length <= 2}
+				onclick={() => removeDoc(idx)}
+			>
+				<X size={14} strokeWidth={2} />
+			</button>
+		</div>
+	{/each}
+	<div class="p-2 text-right">
+		<Button variant="ghost" onclick={() => addDoc()}>
+			<Plus size={14} strokeWidth={2} />
+			Add
+		</Button>
+	</div>
+	<TestResult {error} {loading} {response}>
+		{#snippet result()}
+			<div class="flex h-48 items-center justify-center text-sm text-white/50">No errors</div>
+		{/snippet}
+	</TestResult>
+</GlassPane>

+ 113 - 0
src/routes/api/workspaces/[workspaceId]/models/[id]/test/+server.ts

@@ -0,0 +1,113 @@
+import { json } from '@sveltejs/kit';
+
+import type {
+	ChatCompletionRequest,
+	EmbeddingRequest,
+	EmbeddingResponse,
+	RerankRequest,
+	RerankResponse,
+} from '$lib/api/models';
+import { ApiError, type TestResponse } from '$lib/api/types';
+import { NotFoundError, server } from '$lib/server';
+
+import type { RequestHandler } from './$types';
+
+const sendChatCompletionRequest = async (
+	modelName: string,
+	request: ChatCompletionRequest,
+): Promise<Response> => {
+	const { messages, parameters } = request;
+	const payload: Record<string, unknown> = { model: modelName, messages, stream: true };
+	if (parameters?.temperature !== undefined)
+		payload.temperature = parseFloat(parameters.temperature);
+	if (parameters?.maxTokens !== undefined) payload.max_tokens = parameters.maxTokens;
+	if (parameters?.topP !== undefined) payload.top_p = parseFloat(parameters.topP);
+
+	const gatewayRes = await server.gateway.fetch('/v1/chat/completions', {
+		method: 'POST',
+		headers: { 'Content-Type': 'application/json' },
+		body: JSON.stringify(payload),
+	});
+
+	if (!gatewayRes.ok) {
+		const errText = await gatewayRes.text();
+		return new Response(errText, {
+			status: gatewayRes.status,
+			headers: { 'Content-Type': 'application/json' },
+		});
+	}
+
+	return new Response(gatewayRes.body, {
+		headers: {
+			'Content-Type': 'text/event-stream',
+			'Cache-Control': 'no-cache',
+			'X-Accel-Buffering': 'no',
+		},
+	});
+};
+
+const sendEmbeddingRequest = async (
+	modelName: string,
+	request: EmbeddingRequest,
+): Promise<EmbeddingResponse['response']> => {
+	const resp = await server.gateway.fetch('/v1/embeddings', {
+		method: 'POST',
+		headers: { 'Content-Type': 'application/json' },
+		body: JSON.stringify({ model: modelName, input: request }),
+	});
+	if (!resp.ok) {
+		throw new ApiError(resp.status, 'Embedding API request failed', await resp.text());
+	}
+	const data: EmbeddingResponse['response'] = await resp.json();
+	return data;
+};
+
+const sendRerankRequest = async (
+	modelId: string,
+	request: RerankRequest,
+): Promise<RerankResponse['response']> => {
+	const { query, documents } = request;
+	const resp = await server.gateway.fetch('/v1/rerank', {
+		method: 'POST',
+		headers: { 'Content-Type': 'application/json' },
+		body: JSON.stringify({ model: modelId, query, documents }),
+	});
+	if (!resp.ok) {
+		throw new ApiError(resp.status, 'Rerank API request failed', await resp.text());
+	}
+	const data: RerankResponse['response'] = await resp.json();
+	return data;
+};
+
+const POST: RequestHandler = async ({ locals, params, request }) => {
+	const workspaceId = locals.workspaceId || '';
+	const models = await server.catalog.getWorkspaceCatalog(workspaceId)?.models.list();
+	const model = models?.find((m) => m.id === params.id);
+	if (!model) {
+		throw new NotFoundError('Model not found');
+	}
+	const input = await request.json();
+	if (model.category === 'language') {
+		// For language models, we want to proxy the request to the gateway to support streaming responses
+		return sendChatCompletionRequest(model.name, input as ChatCompletionRequest);
+	}
+
+	const testResp: TestResponse<unknown> = {};
+	const beginMs = Date.now();
+	try {
+		if (model.category === 'embedding') {
+			testResp.response = await sendEmbeddingRequest(model.name, input as EmbeddingRequest);
+		} else if (model.category === 'reranker') {
+			testResp.response = await sendRerankRequest(model.name, input as RerankRequest);
+		} else {
+			return json({ code: 'BAD_REQUEST', message: 'Invalid category' }, { status: 400 });
+		}
+		testResp.latencyMs = Date.now() - beginMs;
+	} catch (err) {
+		if (err instanceof ApiError) testResp.error = { type: err.code, message: err.message };
+		else throw err;
+	}
+	return json(testResp);
+};
+
+export { POST };

+ 13 - 0
src/routes/utilities/testing/model/+page.server.ts

@@ -0,0 +1,13 @@
+import * as modelsApi from '$lib/api/models';
+
+import type { PageServerLoad } from './$types';
+
+const load: PageServerLoad = async ({ fetch, url, locals }) => {
+	const { workspaceId } = locals;
+	if (!workspaceId) return { models: [], selectedModelId: null };
+	const models = await modelsApi.list(workspaceId, fetch);
+	const selectedModelId = url.searchParams.get('model');
+	return { models, selectedModelId };
+};
+
+export { load };

+ 75 - 0
src/routes/utilities/testing/model/+page.svelte

@@ -0,0 +1,75 @@
+<script lang="ts">
+	import { BrainCircuit } from '@lucide/svelte';
+
+	import { resolve } from '$app/paths';
+
+	import { GlassPane } from '$lib/components/common/glass-pane';
+	import { Icon } from '$lib/components/common/icon';
+	import { Button } from '$lib/components/controls/button';
+	import { ModelSelectDialog } from '$lib/components/models/select';
+	import { ChatTest, EmbeddingTest, RerankTest } from '$lib/components/models/testing';
+	import { getBreadcrumbContext } from '$lib/stores/breadcrumb.svelte';
+	import type { Model } from '$lib/types/entities';
+
+	import type { PageData } from './$types';
+
+	const { data }: { data: PageData } = $props();
+
+	const breadcrumb = getBreadcrumbContext();
+	$effect(() => {
+		breadcrumb.set({
+			module: 'Models',
+			pages: [{ name: 'Inference', href: resolve('/utilities/testing/model') }],
+		});
+	});
+
+	let modelSelectionShown = $state(false);
+	let selectedModel = $state<Model | null>(null);
+
+	$effect.root(() => {
+		if (data.selectedModelId) {
+			const found = data.models.find((m) => m.id === data.selectedModelId);
+			if (found) selectedModel = found;
+		}
+	});
+
+	const onselect = (model: Model) => {
+		selectedModel = model;
+		modelSelectionShown = false;
+	};
+</script>
+
+<svelte:head>
+	<title>Model Inference - LocoStack</title>
+</svelte:head>
+
+<div class="mx-auto max-w-4xl p-6">
+	<div class="mb-4 flex items-center gap-2 text-xl font-semibold text-white/90">
+		<BrainCircuit opacity={0.5} size={18} strokeWidth={3} />
+		Model Inference
+	</div>
+	<div class="mb-4">
+		<Button size="sm" variant="outline" onclick={() => (modelSelectionShown = true)}>
+			{#if selectedModel}
+				<Icon icon={selectedModel.icon} size={14} />
+				<div>{selectedModel.name}</div>
+			{:else}
+				Select model...
+			{/if}
+		</Button>
+	</div>
+
+	{#if !selectedModel}
+		<GlassPane class="flex items-center justify-center bg-black/50 p-16">
+			<p class="text-sm text-white/30">Select a model above to begin testing.</p>
+		</GlassPane>
+	{:else if selectedModel.category === 'language'}
+		<ChatTest model={selectedModel} workspaceId={data.workspaceId!} />
+	{:else if selectedModel.category === 'embedding'}
+		<EmbeddingTest model={selectedModel} workspaceId={data.workspaceId!} />
+	{:else if selectedModel.category === 'reranker'}
+		<RerankTest model={selectedModel} workspaceId={data.workspaceId!} />
+	{/if}
+</div>
+
+<ModelSelectDialog workspaceId={data.workspaceId!} bind:open={modelSelectionShown} {onselect} />