mirror of
https://github.com/Routstr/routstr-core.git
synced 2026-10-05 12:28:22 +00:00
chore: remove model testing endpoint and UI
This commit is contained in:
+2
-145
@@ -3,12 +3,11 @@ import json
|
|||||||
import random
|
import random
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
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, UpstreamProviderRow, get_session
|
from ..core.db import ModelRow, 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
|
||||||
@@ -18,24 +17,6 @@ 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
|
||||||
@@ -651,130 +632,6 @@ 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
|
|
||||||
|
|
||||||
|
|
||||||
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")
|
||||||
@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:
|
||||||
|
|||||||
@@ -193,39 +193,3 @@ def test_shape_body_keeps_a_single_cache_control_marker() -> None:
|
|||||||
|
|
||||||
assert json.dumps(shaped).count('"cache_control"') == 1
|
assert json.dumps(shaped).count('"cache_control"') == 1
|
||||||
assert shaped["model"] == "claude-sonnet-4-5-20250929"
|
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';
|
'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';
|
||||||
@@ -19,14 +18,6 @@ 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>(
|
||||||
@@ -104,115 +95,63 @@ 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 and validate endpoints from one place.'
|
description='Manage provider model catalogs.'
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Tabs defaultValue='manage' className='w-full gap-3 sm:gap-4'>
|
{isLoadingModels ? (
|
||||||
<TabsList
|
<div className='space-y-4'>
|
||||||
variant='line'
|
<Skeleton className='h-16 w-full' />
|
||||||
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'
|
<Skeleton className='h-[420px] w-full' />
|
||||||
>
|
</div>
|
||||||
<TabsTrigger
|
) : modelsError ? (
|
||||||
value='manage'
|
<Alert variant='destructive'>
|
||||||
className='h-9 snap-start px-2 text-[13px] sm:h-10 sm:px-2.5 sm:text-sm'
|
<AlertCircle className='h-4 w-4' />
|
||||||
>
|
<AlertDescription>
|
||||||
Manage Models
|
Failed to load models. Please try refreshing the page.
|
||||||
</TabsTrigger>
|
</AlertDescription>
|
||||||
<TabsTrigger
|
</Alert>
|
||||||
value='test-basic'
|
) : (
|
||||||
className='h-9 snap-start px-2 text-[13px] sm:h-10 sm:px-2.5 sm:text-sm'
|
<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'>
|
||||||
Basic Testing
|
<Select
|
||||||
</TabsTrigger>
|
value={activeProviderScope}
|
||||||
</TabsList>
|
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'>
|
<ModelSearchFilter
|
||||||
{isLoadingModels ? (
|
models={scopedModels}
|
||||||
<div className='space-y-4'>
|
onFilteredModelsChange={setFilteredModels}
|
||||||
<Skeleton className='h-16 w-full' />
|
className='w-full min-w-0 flex-1'
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
{isLoadingModels ? (
|
|
||||||
<div className='space-y-4'>
|
<ModelSelector
|
||||||
<Skeleton className='h-[220px] w-full' />
|
filterProvider={
|
||||||
<Skeleton className='h-[120px] w-full' />
|
activeProviderScope === 'all' ? undefined : activeProviderScope
|
||||||
</div>
|
}
|
||||||
) : modelsError ? (
|
groupData={selectedProviderGroup}
|
||||||
<Alert variant='destructive'>
|
filteredModels={filteredModels}
|
||||||
<AlertCircle className='h-4 w-4' />
|
showDeleteAllButton={activeProviderScope === 'all'}
|
||||||
<AlertDescription>
|
/>
|
||||||
Failed to load models for testing. Please try refreshing the
|
</div>
|
||||||
page.
|
)}
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
) : (
|
|
||||||
<ModelTester models={models} />
|
|
||||||
)}
|
|
||||||
</TabsContent>
|
|
||||||
</Tabs>
|
|
||||||
</div>
|
</div>
|
||||||
</AppPageShell>
|
</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 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>;
|
||||||
|
|||||||
@@ -481,29 +481,4 @@ 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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user