revert: keep /api/models/test and Basic Testing until #760 lands

This commit is contained in:
9qeklajc
2026-10-03 01:24:42 +02:00
parent f291f00f16
commit b94b175c81
6 changed files with 949 additions and 55 deletions
+110 -2
View File
@@ -3,11 +3,12 @@ import json
import random import random
import httpx import httpx
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel as V2BaseModel
from pydantic.v1 import BaseModel, validator from pydantic.v1 import BaseModel, validator
from sqlmodel.ext.asyncio.session import AsyncSession from sqlmodel.ext.asyncio.session import AsyncSession
from ..core.db import ModelRow, get_session from ..core.db import ModelRow, UpstreamProviderRow, get_session
from ..core.logging import get_logger from ..core.logging import get_logger
from ..core.settings import settings from ..core.settings import settings
from .price import sats_usd_price from .price import sats_usd_price
@@ -17,6 +18,24 @@ logger = get_logger(__name__)
models_router = APIRouter() models_router = APIRouter()
_MODEL_TEST_ENDPOINT_PATHS = {
"chat-completions": "chat/completions",
"completions": "completions",
"embeddings": "embeddings",
"responses": "responses",
}
# Cap the caller-supplied test payload to avoid forwarding oversized bodies
# upstream on the operator's credentials.
_MODEL_TEST_MAX_REQUEST_BYTES = 64 * 1024
async def _require_admin_api(request: Request) -> None:
"""Require admin auth without creating an import-time cycle with core.admin."""
from ..core.admin import require_admin_api
await require_admin_api(request)
class Architecture(BaseModel): class Architecture(BaseModel):
modality: str modality: str
@@ -630,6 +649,95 @@ async def update_sats_pricing() -> None:
logger.error(f"Error updating sats pricing: {e}") logger.error(f"Error updating sats pricing: {e}")
class ModelTestRequest(V2BaseModel):
model_id: str
endpoint_type: str
request_data: dict
@models_router.post("/api/models/test", dependencies=[Depends(_require_admin_api)])
async def test_model(
payload: ModelTestRequest,
session: AsyncSession = Depends(get_session),
) -> dict:
"""Test a model by sending a request through its configured upstream provider."""
from sqlmodel import select
result = await session.execute(
select(ModelRow).where(ModelRow.id == payload.model_id)
)
model_row = result.scalars().first()
if not model_row:
return {
"success": False,
"error": f"Model '{payload.model_id}' not found in database",
"status_code": 404,
}
provider = await session.get(UpstreamProviderRow, model_row.upstream_provider_id)
if not provider:
return {
"success": False,
"error": "Upstream provider not found",
"status_code": 404,
}
endpoint_path = _MODEL_TEST_ENDPOINT_PATHS.get(payload.endpoint_type)
if endpoint_path is None:
raise HTTPException(status_code=400, detail="Unsupported endpoint_type")
actual_model_id = model_row.forwarded_model_id or model_row.id
request_data = dict(payload.request_data)
request_data["model"] = actual_model_id
try:
request_size = len(json.dumps(request_data).encode("utf-8"))
except (TypeError, ValueError):
raise HTTPException(status_code=400, detail="Invalid request_data")
if request_size > _MODEL_TEST_MAX_REQUEST_BYTES:
raise HTTPException(status_code=413, detail="request_data too large")
base_url = provider.base_url.rstrip("/")
url = f"{base_url}/{endpoint_path}"
logger.info(
"admin model test",
extra={
"model_id": payload.model_id,
"forwarded_model_id": actual_model_id,
"endpoint_type": payload.endpoint_type,
"upstream_provider_id": model_row.upstream_provider_id,
"request_bytes": request_size,
},
)
headers = {
"Content-Type": "application/json",
"Authorization": f"Bearer {provider.api_key}",
}
try:
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.post(url, json=request_data, headers=headers)
try:
response_data = response.json()
except Exception:
response_data = {"raw": response.text}
return {
"success": response.status_code < 400,
"data": response_data,
"status_code": response.status_code,
}
except Exception as e:
return {
"success": False,
"error": str(e),
"status_code": 500,
}
@models_router.get("/v1/models/paths") @models_router.get("/v1/models/paths")
@models_router.get("/v1/models/paths/", include_in_schema=False) @models_router.get("/v1/models/paths/", include_in_schema=False)
async def model_paths() -> dict: async def model_paths() -> dict:
@@ -0,0 +1,224 @@
import time
from types import TracebackType
from typing import Any
from unittest.mock import patch
import pytest
from httpx import AsyncClient
from routstr.core.admin import admin_sessions
from routstr.core.db import ModelRow, UpstreamProviderRow
@pytest.mark.integration
@pytest.mark.asyncio
async def test_model_test_endpoint_requires_admin_auth(
integration_client: AsyncClient,
) -> None:
with patch("httpx.AsyncClient") as mock_async_client:
response = await integration_client.post(
"/api/models/test",
json={
"model_id": "model-a",
"endpoint_type": "chat-completions",
"request_data": {"messages": []},
},
)
assert response.status_code == 403
mock_async_client.assert_not_called()
@pytest.mark.integration
@pytest.mark.asyncio
async def test_model_test_endpoint_rejects_unsupported_endpoint_type(
integration_client: AsyncClient,
integration_session: Any,
) -> None:
admin_token = "test-admin-token-model-test"
admin_sessions[admin_token] = int(time.time()) + 3600
integration_client.headers["Authorization"] = f"Bearer {admin_token}"
provider = UpstreamProviderRow(
provider_type="custom",
base_url="https://api.example.com/v1",
api_key="sk-upstream-test",
enabled=True,
provider_fee=1.01,
)
integration_session.add(provider)
await integration_session.commit()
await integration_session.refresh(provider)
assert provider.id is not None
model = ModelRow(
id="model-a",
name="Model A",
created=1,
description="desc",
context_length=100,
architecture='{"modality": "text", "input_modalities": ["text"], "output_modalities": ["text"], "tokenizer": "tiktoken", "instruct_type": "chat"}',
pricing='{"prompt": 1.0, "completion": 1.0}',
upstream_provider_id=provider.id,
enabled=True,
)
integration_session.add(model)
await integration_session.commit()
try:
with patch("httpx.AsyncClient") as mock_async_client:
response = await integration_client.post(
"/api/models/test",
json={
"model_id": "model-a",
"endpoint_type": "../../abuse",
"request_data": {"messages": []},
},
)
assert response.status_code == 400
assert response.json()["detail"] == "Unsupported endpoint_type"
mock_async_client.assert_not_called()
finally:
admin_sessions.pop(admin_token, None)
@pytest.mark.integration
@pytest.mark.asyncio
async def test_model_test_endpoint_rejects_oversized_request_data(
integration_client: AsyncClient,
integration_session: Any,
) -> None:
admin_token = "test-admin-token-model-test-oversized"
admin_sessions[admin_token] = int(time.time()) + 3600
integration_client.headers["Authorization"] = f"Bearer {admin_token}"
provider = UpstreamProviderRow(
provider_type="custom",
base_url="https://api.example.com/v1",
api_key="sk-upstream-test",
enabled=True,
provider_fee=1.01,
)
integration_session.add(provider)
await integration_session.commit()
await integration_session.refresh(provider)
assert provider.id is not None
model = ModelRow(
id="model-a",
name="Model A",
created=1,
description="desc",
context_length=100,
architecture='{"modality": "text", "input_modalities": ["text"], "output_modalities": ["text"], "tokenizer": "tiktoken", "instruct_type": "chat"}',
pricing='{"prompt": 1.0, "completion": 1.0}',
upstream_provider_id=provider.id,
enabled=True,
)
integration_session.add(model)
await integration_session.commit()
oversized = "x" * (64 * 1024 + 1)
try:
with patch("httpx.AsyncClient") as mock_async_client:
response = await integration_client.post(
"/api/models/test",
json={
"model_id": "model-a",
"endpoint_type": "chat-completions",
"request_data": {"blob": oversized},
},
)
assert response.status_code == 413
assert response.json()["detail"] == "request_data too large"
mock_async_client.assert_not_called()
finally:
admin_sessions.pop(admin_token, None)
@pytest.mark.integration
@pytest.mark.asyncio
async def test_model_test_endpoint_admin_uses_allowed_upstream_path(
integration_client: AsyncClient,
integration_session: Any,
) -> None:
admin_token = "test-admin-token-model-test-success"
admin_sessions[admin_token] = int(time.time()) + 3600
integration_client.headers["Authorization"] = f"Bearer {admin_token}"
provider = UpstreamProviderRow(
provider_type="custom",
base_url="https://api.example.com/v1",
api_key="sk-upstream-test",
enabled=True,
provider_fee=1.01,
)
integration_session.add(provider)
await integration_session.commit()
await integration_session.refresh(provider)
assert provider.id is not None
model = ModelRow(
id="model-a",
name="Model A",
created=1,
description="desc",
context_length=100,
architecture='{"modality": "text", "input_modalities": ["text"], "output_modalities": ["text"], "tokenizer": "tiktoken", "instruct_type": "chat"}',
pricing='{"prompt": 1.0, "completion": 1.0}',
upstream_provider_id=provider.id,
enabled=True,
forwarded_model_id="upstream-model-a",
)
integration_session.add(model)
await integration_session.commit()
class MockResponse:
status_code = 200
text = '{"ok": true}'
def json(self) -> dict[str, bool]:
return {"ok": True}
class MockAsyncClient:
async def __aenter__(self) -> "MockAsyncClient":
return self
async def __aexit__(
self,
exc_type: type[BaseException] | None,
exc: BaseException | None,
tb: TracebackType | None,
) -> None:
return None
async def post(
self, url: str, json: dict[str, Any], headers: dict[str, str]
) -> MockResponse:
assert url == "https://api.example.com/v1/chat/completions"
assert json["model"] == "upstream-model-a"
assert headers["Authorization"] == "Bearer sk-upstream-test"
return MockResponse()
try:
with patch("httpx.AsyncClient", return_value=MockAsyncClient()):
response = await integration_client.post(
"/api/models/test",
json={
"model_id": "model-a",
"endpoint_type": "chat-completions",
"request_data": {"messages": []},
},
)
assert response.status_code == 200
assert response.json() == {
"success": True,
"data": {"ok": True},
"status_code": 200,
}
finally:
admin_sessions.pop(admin_token, None)
+450
View File
@@ -0,0 +1,450 @@
'use client';
import React, { useState } from 'react';
import { useMutation, useQuery } from '@tanstack/react-query';
import { type Model } from '@/lib/api/schemas/models';
import { ModelService } from '@/lib/api/services/models';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Badge } from '@/components/ui/badge';
import { Alert, AlertDescription } from '@/components/ui/alert';
import {
Loader2,
Send,
CheckCircle,
XCircle,
Info,
Key,
Globe,
} from 'lucide-react';
import { toast } from 'sonner';
interface ModelTesterProps {
models: Model[];
}
interface ChatCompletionRequest {
model: string;
messages: {
role: 'system' | 'user' | 'assistant';
content: string;
}[];
max_tokens?: number;
temperature?: number;
}
interface ChatCompletionResponse {
id: string;
object: string;
created: number;
model: string;
choices: {
index: number;
message: {
role: string;
content: string;
};
finish_reason: string;
}[];
usage?: {
prompt_tokens: number;
completion_tokens: number;
total_tokens: number;
};
}
const DEFAULT_SYSTEM_MESSAGE =
'You are a helpful assistant. Please respond concisely.';
const DEFAULT_USER_MESSAGE =
'Hello! Can you tell me what model you are and confirm that you are working correctly?';
export function ModelTester({ models }: ModelTesterProps) {
const [selectedModelId, setSelectedModelId] = useState<string>('');
const [systemMessage, setSystemMessage] = useState(DEFAULT_SYSTEM_MESSAGE);
const [userMessage, setUserMessage] = useState(DEFAULT_USER_MESSAGE);
const [maxTokens, setMaxTokens] = useState<number>(150);
const [temperature, setTemperature] = useState<number>(0.7);
const [response, setResponse] = useState<ChatCompletionResponse | null>(null);
const [error, setError] = useState<string | null>(null);
// Fetch model groups for API key resolution
const { data: groups = [] } = useQuery({
queryKey: ['model-groups'],
queryFn: () => ModelService.getModelGroups(),
refetchOnWindowFocus: false,
});
const selectedModel = models.find((model) => model.id === selectedModelId);
// Get effective API key and endpoint URL for the selected model
const getModelCredentials = (model: Model) => {
const group = groups.find((g) => g.provider === model.provider);
// Determine API key (individual takes precedence over group)
const apiKey = model.api_key || group?.group_api_key;
// Determine endpoint URL
let endpointUrl = model.url;
// If model URL is relative and group has a base URL, combine them
if (model.url.startsWith('/') && group?.group_url) {
endpointUrl = `${group.group_url.replace(/\/$/, '')}${model.url}`;
}
// Ensure the URL ends with /chat/completions for chat models
if (
model.modelType === 'text' &&
!endpointUrl.includes('/chat/completions')
) {
endpointUrl = endpointUrl.replace(/\/$/, '') + '/chat/completions';
}
return {
apiKey,
endpointUrl,
group,
};
};
const testModelMutation = useMutation({
mutationFn: async (request: ChatCompletionRequest) => {
if (!selectedModel) {
throw new Error('No model selected');
}
setError(null);
setResponse(null);
try {
const response = await ModelService.testModel(
selectedModel.id,
'chat-completions',
request
);
if (!response.success) {
throw new Error(response.error || 'Test failed');
}
return response.data as ChatCompletionResponse;
} catch (err: unknown) {
console.error('Model test error via proxy:', err);
const errorMessage =
err instanceof Error ? err.message : 'Failed to test model via proxy';
throw new Error(errorMessage);
}
},
onSuccess: (data) => {
setResponse(data);
toast.success('Model test completed successfully!');
},
onError: (err: Error) => {
const errorMessage = err?.message || 'Unknown error occurred';
setError(errorMessage);
toast.error(`Model test failed: ${errorMessage}`);
},
});
const handleTest = async () => {
if (!selectedModel) {
toast.error('Please select a model to test');
return;
}
if (!userMessage.trim()) {
toast.error('Please enter a test message');
return;
}
const messages = [];
if (systemMessage.trim()) {
messages.push({
role: 'system' as const,
content: systemMessage.trim(),
});
}
messages.push({
role: 'user' as const,
content: userMessage.trim(),
});
const request: ChatCompletionRequest = {
model: selectedModel.name,
messages,
max_tokens: maxTokens,
temperature: temperature,
};
testModelMutation.mutate(request);
};
const enabledModels = Array.from(
new Map(
models.filter((model) => model.isEnabled).map((m) => [m.id, m])
).values()
);
const credentials = selectedModel ? getModelCredentials(selectedModel) : null;
return (
<Card className='w-full'>
<CardHeader>
<CardTitle>Model Credential Tester</CardTitle>
<CardDescription>
Test model functionality by sending chat completion requests through
the secure proxy (resolves CORS and network issues)
</CardDescription>
</CardHeader>
<CardContent className='space-y-6'>
{/* Model Selection */}
<div className='space-y-2'>
<Label htmlFor='model-select'>Select Model</Label>
<Select value={selectedModelId} onValueChange={setSelectedModelId}>
<SelectTrigger id='model-select'>
<SelectValue placeholder='Choose a model to test...' />
</SelectTrigger>
<SelectContent>
{enabledModels.map((model) => (
<SelectItem key={model.id} value={model.id}>
<div className='flex items-center gap-2'>
<span>{model.name}</span>
<Badge variant='outline' className='text-xs'>
{model.provider}
</Badge>
{model.is_free && (
<Badge variant='secondary' className='text-xs'>
Free
</Badge>
)}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
{selectedModel && credentials && (
<div className='text-muted-foreground bg-muted space-y-2 rounded-md p-3 text-sm'>
<div className='flex items-center gap-2'>
<Globe className='h-4 w-4' />
<span className='break-all'>
<strong>Endpoint:</strong> {credentials.endpointUrl}
</span>
</div>
<div className='flex items-center gap-2'>
<Key className='h-4 w-4' />
<span>
<strong>API Key:</strong>{' '}
{credentials.apiKey
? `${credentials.apiKey.substring(0, 8)}...`
: 'Not configured'}
</span>
<Badge
variant={credentials.apiKey ? 'default' : 'destructive'}
className='text-xs'
>
{selectedModel.api_key_type || 'Unknown'}
</Badge>
</div>
<div>
<span>
<strong>Provider:</strong> {selectedModel.provider}
</span>
</div>
<div>
<span>
<strong>Type:</strong> {selectedModel.modelType}
</span>
</div>
{selectedModel.contextLength && (
<div>
<span>
<strong>Context Length:</strong>{' '}
{selectedModel.contextLength.toLocaleString()}
</span>
</div>
)}
{!credentials.apiKey && (
<Alert variant='default' className='mt-2'>
<Info className='h-4 w-4' />
<AlertDescription>
No API key configured for this model. Testing may still work
if the model is free or if authentication is handled
elsewhere. For models requiring authentication, please add
an API key to the model or its provider group.
</AlertDescription>
</Alert>
)}
</div>
)}
</div>
{/* Test Parameters */}
<div className='grid grid-cols-1 gap-4 md:grid-cols-2'>
<div className='space-y-2'>
<Label htmlFor='max-tokens'>Max Tokens</Label>
<Input
id='max-tokens'
type='number'
min={1}
max={4000}
value={maxTokens}
onChange={(e) => setMaxTokens(parseInt(e.target.value) || 150)}
name='max_tokens'
/>
</div>
<div className='space-y-2'>
<Label htmlFor='temperature'>Temperature</Label>
<Input
id='temperature'
type='number'
min={0}
max={2}
step={0.1}
value={temperature}
onChange={(e) =>
setTemperature(parseFloat(e.target.value) || 0.7)
}
name='temperature'
/>
</div>
</div>
{/* System Message */}
<div className='space-y-2'>
<Label htmlFor='system-message'>System Message (Optional)</Label>
<Textarea
id='system-message'
placeholder='Enter system message...'
value={systemMessage}
onChange={(e) => setSystemMessage(e.target.value)}
rows={2}
/>
</div>
{/* User Message */}
<div className='space-y-2'>
<Label htmlFor='user-message'>Test Message</Label>
<Textarea
id='user-message'
placeholder='Enter your test message...'
value={userMessage}
onChange={(e) => setUserMessage(e.target.value)}
rows={3}
/>
</div>
{/* Test Button */}
<Button
onClick={handleTest}
disabled={!selectedModelId || testModelMutation.isPending}
className='w-full'
>
{testModelMutation.isPending ? (
<>
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
Testing Model...
</>
) : (
<>
<Send className='mr-2 h-4 w-4' />
Test Model (via Proxy)
</>
)}
</Button>
{/* Results */}
{error && (
<Alert variant='destructive'>
<XCircle className='h-4 w-4' />
<AlertDescription>
<strong>Test Failed:</strong> {error}
</AlertDescription>
</Alert>
)}
{response && (
<Alert>
<CheckCircle className='h-4 w-4' />
<AlertDescription>
<strong>Test Successful!</strong> Model responded correctly via
secure proxy.
</AlertDescription>
</Alert>
)}
{response && (
<div className='space-y-4'>
<div className='space-y-2'>
<Label>Model Response</Label>
<div className='bg-muted rounded-md p-4'>
<p className='text-sm whitespace-pre-wrap'>
{response.choices[0]?.message?.content ||
'No content in response'}
</p>
</div>
</div>
{response.usage && (
<div className='space-y-2'>
<Label>Usage Statistics</Label>
<div className='grid grid-cols-1 gap-2 text-sm sm:grid-cols-3 sm:gap-4'>
<div className='bg-muted rounded p-2 text-center'>
<div className='font-semibold'>
{response.usage.prompt_tokens}
</div>
<div className='text-muted-foreground'>Prompt Tokens</div>
</div>
<div className='bg-muted rounded p-2 text-center'>
<div className='font-semibold'>
{response.usage.completion_tokens}
</div>
<div className='text-muted-foreground'>
Completion Tokens
</div>
</div>
<div className='bg-muted rounded p-2 text-center'>
<div className='font-semibold'>
{response.usage.total_tokens}
</div>
<div className='text-muted-foreground'>Total Tokens</div>
</div>
</div>
</div>
)}
<div className='space-y-2'>
<Label>Raw Response</Label>
<details className='group'>
<summary className='text-muted-foreground hover:text-foreground cursor-pointer text-sm'>
<Info className='mr-1 inline h-4 w-4' />
Show detailed response data
</summary>
<pre className='bg-muted mt-2 max-h-60 overflow-auto rounded-md p-4 text-xs'>
{JSON.stringify(response, null, 2)}
</pre>
</details>
</div>
</div>
)}
</CardContent>
</Card>
);
}
+63 -2
View File
@@ -1,6 +1,7 @@
'use client'; 'use client';
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import dynamic from 'next/dynamic';
import { AlertCircle } from 'lucide-react'; import { AlertCircle } from 'lucide-react';
import type { Model } from '@/lib/api/schemas/models'; import type { Model } from '@/lib/api/schemas/models';
import { useModelsWithProviders } from '@/lib/hooks/use-models-with-providers'; import { useModelsWithProviders } from '@/lib/hooks/use-models-with-providers';
@@ -18,6 +19,14 @@ import {
SelectValue, SelectValue,
} from '@/components/ui/select'; } from '@/components/ui/select';
import { Skeleton } from '@/components/ui/skeleton'; import { Skeleton } from '@/components/ui/skeleton';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
// The testing tab is never the landing view, so keeping it out of this
// route's chunk is what lets the navigation itself resolve quickly.
const ModelTester = dynamic(
() => import('@/components/model-tester').then((m) => m.ModelTester),
{ loading: () => <Skeleton className='h-[420px] w-full' />, ssr: false }
);
export function ModelsPage() { export function ModelsPage() {
const [filteredModels, setFilteredModels] = useState<Model[] | undefined>( const [filteredModels, setFilteredModels] = useState<Model[] | undefined>(
@@ -95,9 +104,29 @@ export function ModelsPage() {
<div className='space-y-3 sm:space-y-4'> <div className='space-y-3 sm:space-y-4'>
<PageHeader <PageHeader
title='Model Management' title='Model Management'
description='Manage provider model catalogs.' description='Manage provider model catalogs and validate endpoints from one place.'
/> />
<Tabs defaultValue='manage' className='w-full gap-3 sm:gap-4'>
<TabsList
variant='line'
className='w-full snap-x snap-mandatory justify-start gap-0.5 overflow-x-auto whitespace-nowrap [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden'
>
<TabsTrigger
value='manage'
className='h-9 snap-start px-2 text-[13px] sm:h-10 sm:px-2.5 sm:text-sm'
>
Manage Models
</TabsTrigger>
<TabsTrigger
value='test-basic'
className='h-9 snap-start px-2 text-[13px] sm:h-10 sm:px-2.5 sm:text-sm'
>
Basic Testing
</TabsTrigger>
</TabsList>
<TabsContent value='manage' className='mt-0'>
{isLoadingModels ? ( {isLoadingModels ? (
<div className='space-y-4'> <div className='space-y-4'>
<Skeleton className='h-16 w-full' /> <Skeleton className='h-16 w-full' />
@@ -144,7 +173,9 @@ export function ModelsPage() {
<ModelSelector <ModelSelector
filterProvider={ filterProvider={
activeProviderScope === 'all' ? undefined : activeProviderScope activeProviderScope === 'all'
? undefined
: activeProviderScope
} }
groupData={selectedProviderGroup} groupData={selectedProviderGroup}
filteredModels={filteredModels} filteredModels={filteredModels}
@@ -152,6 +183,36 @@ export function ModelsPage() {
/> />
</div> </div>
)} )}
</TabsContent>
<TabsContent value='test-basic' className='mt-0 space-y-3'>
<div className='space-y-1'>
<h3 className='text-base font-semibold'>
Basic Credential Testing
</h3>
<p className='text-muted-foreground text-sm'>
Run chat-completion checks through the secure proxy to validate
model credentials and endpoint connectivity.
</p>
</div>
{isLoadingModels ? (
<div className='space-y-4'>
<Skeleton className='h-[220px] w-full' />
<Skeleton className='h-[120px] w-full' />
</div>
) : modelsError ? (
<Alert variant='destructive'>
<AlertCircle className='h-4 w-4' />
<AlertDescription>
Failed to load models for testing. Please try refreshing the
page.
</AlertDescription>
</Alert>
) : (
<ModelTester models={models} />
)}
</TabsContent>
</Tabs>
</div> </div>
</AppPageShell> </AppPageShell>
); );
+26
View File
@@ -111,11 +111,37 @@ export const ModelListResponseSchema = z.object({
}), }),
}); });
// Schema for model testing request
export const ModelTestRequestSchema = z.object({
modelId: z.string(),
input: z.string(),
parameters: z.record(z.string(), z.unknown()).optional(),
});
// Schema for model testing response
export const ModelTestResponseSchema = z.object({
output: z.string(),
usage: z
.object({
promptTokens: z.number().optional(),
completionTokens: z.number().optional(),
totalTokens: z.number().optional(),
})
.optional(),
timings: z
.object({
totalMs: z.number(),
})
.optional(),
});
// Export types derived from the schemas // Export types derived from the schemas
export type Model = z.infer<typeof ModelSchema>; export type Model = z.infer<typeof ModelSchema>;
export type ModelWithSettings = z.infer<typeof ModelWithSettingsSchema>; export type ModelWithSettings = z.infer<typeof ModelWithSettingsSchema>;
export type CreateModel = z.infer<typeof CreateModelSchema>; export type CreateModel = z.infer<typeof CreateModelSchema>;
export type UpdateModel = z.infer<typeof UpdateModelSchema>; export type UpdateModel = z.infer<typeof UpdateModelSchema>;
export type ModelListResponse = z.infer<typeof ModelListResponseSchema>; export type ModelListResponse = z.infer<typeof ModelListResponseSchema>;
export type ModelTestRequest = z.infer<typeof ModelTestRequestSchema>;
export type ModelTestResponse = z.infer<typeof ModelTestResponseSchema>;
export type ManualModel = z.infer<typeof ManualModelSchema>; export type ManualModel = z.infer<typeof ManualModelSchema>;
export type GroupSettings = z.infer<typeof GroupSettingsSchema>; export type GroupSettings = z.infer<typeof GroupSettingsSchema>;
+25
View File
@@ -481,4 +481,29 @@ export class ModelService {
throw error; throw error;
} }
} }
// Test model through proxy to avoid CORS issues
static async testModel(
modelId: string,
endpointType: string,
requestData: unknown
): Promise<{
success: boolean;
data?: unknown;
error?: string;
status_code?: number;
}> {
const response = await apiClient.post<{
success: boolean;
data?: unknown;
error?: string;
status_code?: number;
}>('/api/models/test', {
model_id: modelId,
endpoint_type: endpointType,
request_data: requestData,
});
return response;
}
} }