mirror of
https://github.com/Routstr/routstr-core.git
synced 2026-10-05 12:28:22 +00:00
Merge pull request #802 from Routstr/chore/remove-model-testing
chore: remove model testing endpoint and UI
This commit is contained in:
+2
-145
@@ -3,12 +3,11 @@ import json
|
||||
import random
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from pydantic import BaseModel as V2BaseModel
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic.v1 import BaseModel, validator
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
from ..core.db import ModelRow, UpstreamProviderRow, get_session
|
||||
from ..core.db import ModelRow, get_session
|
||||
from ..core.logging import get_logger
|
||||
from ..core.settings import settings
|
||||
from .price import sats_usd_price
|
||||
@@ -18,24 +17,6 @@ logger = get_logger(__name__)
|
||||
|
||||
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):
|
||||
modality: str
|
||||
@@ -651,130 +632,6 @@ async def update_sats_pricing() -> None:
|
||||
logger.error(f"Error updating sats pricing: {e}")
|
||||
|
||||
|
||||
class ModelTestRequest(V2BaseModel):
|
||||
model_id: str
|
||||
endpoint_type: str
|
||||
request_data: dict
|
||||
|
||||
|
||||
def _model_test_target(
|
||||
provider: UpstreamProviderRow,
|
||||
model_row: ModelRow,
|
||||
endpoint_path: str,
|
||||
model_id: str,
|
||||
) -> tuple[str, dict[str, str], dict[str, str], str]:
|
||||
"""URL, headers, query params and model id for a model test, shaped like
|
||||
the proxy's.
|
||||
|
||||
With the provider's live upstream instance, use the hooks
|
||||
``forward_request`` uses (Azure's deployment path, ``api-key`` and
|
||||
``api-version``, Gemini's ``/openai`` base, Ollama's ``/v1``, model-name
|
||||
transforms). Without one, assume a plain OpenAI-compatible base URL.
|
||||
"""
|
||||
from ..proxy import get_upstreams
|
||||
|
||||
upstream = next(
|
||||
(u for u in get_upstreams() if getattr(u, "db_id", None) == provider.id),
|
||||
None,
|
||||
)
|
||||
if upstream is None:
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer {provider.api_key}",
|
||||
}
|
||||
url = f"{provider.base_url.rstrip('/')}/{endpoint_path}"
|
||||
return url, headers, {}, model_id
|
||||
|
||||
model_obj = _build_model_from_row(model_row, False, provider.provider_fee)
|
||||
path = upstream.normalize_request_path(f"v1/{endpoint_path}", model_obj)
|
||||
return (
|
||||
upstream.build_request_url(path, model_obj),
|
||||
upstream.prepare_headers({"content-type": "application/json"}),
|
||||
dict(upstream.prepare_params(path, None)),
|
||||
# The proxy forwards ``model.id``, not the row's client alias.
|
||||
upstream.transform_model_name(model_obj.id),
|
||||
)
|
||||
|
||||
|
||||
@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
|
||||
url, headers, params, upstream_model_id = _model_test_target(
|
||||
provider, model_row, endpoint_path, actual_model_id
|
||||
)
|
||||
request_data = {**payload.request_data, "model": upstream_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")
|
||||
|
||||
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,
|
||||
},
|
||||
)
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
response = await client.post(
|
||||
url, json=request_data, headers=headers, params=params
|
||||
)
|
||||
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/", include_in_schema=False)
|
||||
async def model_paths() -> dict:
|
||||
|
||||
@@ -193,39 +193,3 @@ def test_shape_body_keeps_a_single_cache_control_marker() -> None:
|
||||
|
||||
assert json.dumps(shaped).count('"cache_control"') == 1
|
||||
assert shaped["model"] == "claude-sonnet-4-5-20250929"
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("shape", SHAPES, ids=[s.provider_type for s in SHAPES])
|
||||
async def test_model_test_matches_proxy_request(
|
||||
shape: Shape,
|
||||
integration_client: AsyncClient,
|
||||
integration_session: AsyncSession,
|
||||
) -> None:
|
||||
"""``POST /api/models/test`` reaches the upstream the way the proxy does."""
|
||||
with respx.mock(assert_all_called=False) as mock:
|
||||
await _seed(integration_session, shape)
|
||||
chat_route = mock.post(shape.chat_url).mock(
|
||||
return_value=Response(200, json=_mock_chat_response(model=shape.model_id))
|
||||
)
|
||||
|
||||
resp = await integration_client.post(
|
||||
"/api/models/test",
|
||||
headers=_admin_headers(),
|
||||
json={
|
||||
"model_id": shape.model_id,
|
||||
"endpoint_type": "chat-completions",
|
||||
"request_data": {"messages": [{"role": "user", "content": "hi"}]},
|
||||
},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert resp.json()["success"] is True, resp.json()
|
||||
assert chat_route.call_count == 1
|
||||
request = chat_route.calls[0].request
|
||||
header, value = shape.auth_header
|
||||
assert request.headers.get(header) == value
|
||||
for key, expected in shape.params.items():
|
||||
assert request.url.params.get(key) == expected
|
||||
assert json.loads(request.content)["model"] == shape.upstream_model
|
||||
|
||||
@@ -1,232 +0,0 @@
|
||||
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],
|
||||
params: dict[str, str] | None = None,
|
||||
) -> 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:
|
||||
# No live upstream instance: the plain OpenAI-compatible fallback.
|
||||
with (
|
||||
patch("httpx.AsyncClient", return_value=MockAsyncClient()),
|
||||
patch("routstr.proxy.get_upstreams", return_value=[]),
|
||||
):
|
||||
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)
|
||||
@@ -1,450 +0,0 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
+53
-114
@@ -1,7 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo, useState } from 'react';
|
||||
import dynamic from 'next/dynamic';
|
||||
import { AlertCircle } from 'lucide-react';
|
||||
import type { Model } from '@/lib/api/schemas/models';
|
||||
import { useModelsWithProviders } from '@/lib/hooks/use-models-with-providers';
|
||||
@@ -19,14 +18,6 @@ import {
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
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() {
|
||||
const [filteredModels, setFilteredModels] = useState<Model[] | undefined>(
|
||||
@@ -104,115 +95,63 @@ export function ModelsPage() {
|
||||
<div className='space-y-3 sm:space-y-4'>
|
||||
<PageHeader
|
||||
title='Model Management'
|
||||
description='Manage provider model catalogs and validate endpoints from one place.'
|
||||
description='Manage provider model catalogs.'
|
||||
/>
|
||||
|
||||
<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>
|
||||
{isLoadingModels ? (
|
||||
<div className='space-y-4'>
|
||||
<Skeleton className='h-16 w-full' />
|
||||
<Skeleton className='h-[420px] w-full' />
|
||||
</div>
|
||||
) : modelsError ? (
|
||||
<Alert variant='destructive'>
|
||||
<AlertCircle className='h-4 w-4' />
|
||||
<AlertDescription>
|
||||
Failed to load models. Please try refreshing the page.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : (
|
||||
<div className='space-y-3 sm:space-y-4'>
|
||||
<div className='flex flex-col gap-2 sm:gap-2.5 md:flex-row md:items-center'>
|
||||
<Select
|
||||
value={activeProviderScope}
|
||||
onValueChange={(value) => {
|
||||
setSelectedProviderScope(value);
|
||||
setFilteredModels(undefined);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className='h-8 w-full md:w-[220px]'>
|
||||
<SelectValue placeholder='Provider scope' />
|
||||
</SelectTrigger>
|
||||
<SelectContent align='start'>
|
||||
<SelectItem value='all'>
|
||||
All providers ({models.length})
|
||||
</SelectItem>
|
||||
{providerInfo.map(({ provider, totalModels }) => (
|
||||
<SelectItem key={provider} value={provider}>
|
||||
{provider} ({totalModels})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<TabsContent value='manage' className='mt-0'>
|
||||
{isLoadingModels ? (
|
||||
<div className='space-y-4'>
|
||||
<Skeleton className='h-16 w-full' />
|
||||
<Skeleton className='h-[420px] w-full' />
|
||||
</div>
|
||||
) : modelsError ? (
|
||||
<Alert variant='destructive'>
|
||||
<AlertCircle className='h-4 w-4' />
|
||||
<AlertDescription>
|
||||
Failed to load models. Please try refreshing the page.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : (
|
||||
<div className='space-y-3 sm:space-y-4'>
|
||||
<div className='flex flex-col gap-2 sm:gap-2.5 md:flex-row md:items-center'>
|
||||
<Select
|
||||
value={activeProviderScope}
|
||||
onValueChange={(value) => {
|
||||
setSelectedProviderScope(value);
|
||||
setFilteredModels(undefined);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className='h-8 w-full md:w-[220px]'>
|
||||
<SelectValue placeholder='Provider scope' />
|
||||
</SelectTrigger>
|
||||
<SelectContent align='start'>
|
||||
<SelectItem value='all'>
|
||||
All providers ({models.length})
|
||||
</SelectItem>
|
||||
{providerInfo.map(({ provider, totalModels }) => (
|
||||
<SelectItem key={provider} value={provider}>
|
||||
{provider} ({totalModels})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<ModelSearchFilter
|
||||
models={scopedModels}
|
||||
onFilteredModelsChange={setFilteredModels}
|
||||
className='w-full min-w-0 flex-1'
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ModelSelector
|
||||
filterProvider={
|
||||
activeProviderScope === 'all'
|
||||
? undefined
|
||||
: activeProviderScope
|
||||
}
|
||||
groupData={selectedProviderGroup}
|
||||
filteredModels={filteredModels}
|
||||
showDeleteAllButton={activeProviderScope === 'all'}
|
||||
/>
|
||||
</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>
|
||||
<ModelSearchFilter
|
||||
models={scopedModels}
|
||||
onFilteredModelsChange={setFilteredModels}
|
||||
className='w-full min-w-0 flex-1'
|
||||
/>
|
||||
</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>
|
||||
|
||||
<ModelSelector
|
||||
filterProvider={
|
||||
activeProviderScope === 'all' ? undefined : activeProviderScope
|
||||
}
|
||||
groupData={selectedProviderGroup}
|
||||
filteredModels={filteredModels}
|
||||
showDeleteAllButton={activeProviderScope === 'all'}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</AppPageShell>
|
||||
);
|
||||
|
||||
@@ -111,37 +111,11 @@ 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 type Model = z.infer<typeof ModelSchema>;
|
||||
export type ModelWithSettings = z.infer<typeof ModelWithSettingsSchema>;
|
||||
export type CreateModel = z.infer<typeof CreateModelSchema>;
|
||||
export type UpdateModel = z.infer<typeof UpdateModelSchema>;
|
||||
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 GroupSettings = z.infer<typeof GroupSettingsSchema>;
|
||||
|
||||
@@ -481,29 +481,4 @@ export class ModelService {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user