mirror of
https://github.com/Routstr/routstr-core.git
synced 2026-10-05 12:28:22 +00:00
revert: keep /api/models/test and Basic Testing until #760 lands
This commit is contained in:
+110
-2
@@ -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)
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
+114
-53
@@ -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,63 +104,115 @@ 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.'
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{isLoadingModels ? (
|
<Tabs defaultValue='manage' className='w-full gap-3 sm:gap-4'>
|
||||||
<div className='space-y-4'>
|
<TabsList
|
||||||
<Skeleton className='h-16 w-full' />
|
variant='line'
|
||||||
<Skeleton className='h-[420px] 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'
|
||||||
</div>
|
>
|
||||||
) : modelsError ? (
|
<TabsTrigger
|
||||||
<Alert variant='destructive'>
|
value='manage'
|
||||||
<AlertCircle className='h-4 w-4' />
|
className='h-9 snap-start px-2 text-[13px] sm:h-10 sm:px-2.5 sm:text-sm'
|
||||||
<AlertDescription>
|
>
|
||||||
Failed to load models. Please try refreshing the page.
|
Manage Models
|
||||||
</AlertDescription>
|
</TabsTrigger>
|
||||||
</Alert>
|
<TabsTrigger
|
||||||
) : (
|
value='test-basic'
|
||||||
<div className='space-y-3 sm:space-y-4'>
|
className='h-9 snap-start px-2 text-[13px] sm:h-10 sm:px-2.5 sm:text-sm'
|
||||||
<div className='flex flex-col gap-2 sm:gap-2.5 md:flex-row md:items-center'>
|
>
|
||||||
<Select
|
Basic Testing
|
||||||
value={activeProviderScope}
|
</TabsTrigger>
|
||||||
onValueChange={(value) => {
|
</TabsList>
|
||||||
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
|
<TabsContent value='manage' className='mt-0'>
|
||||||
models={scopedModels}
|
{isLoadingModels ? (
|
||||||
onFilteredModelsChange={setFilteredModels}
|
<div className='space-y-4'>
|
||||||
className='w-full min-w-0 flex-1'
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
{isLoadingModels ? (
|
||||||
<ModelSelector
|
<div className='space-y-4'>
|
||||||
filterProvider={
|
<Skeleton className='h-[220px] w-full' />
|
||||||
activeProviderScope === 'all' ? undefined : activeProviderScope
|
<Skeleton className='h-[120px] w-full' />
|
||||||
}
|
</div>
|
||||||
groupData={selectedProviderGroup}
|
) : modelsError ? (
|
||||||
filteredModels={filteredModels}
|
<Alert variant='destructive'>
|
||||||
showDeleteAllButton={activeProviderScope === 'all'}
|
<AlertCircle className='h-4 w-4' />
|
||||||
/>
|
<AlertDescription>
|
||||||
</div>
|
Failed to load models for testing. Please try refreshing the
|
||||||
)}
|
page.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
) : (
|
||||||
|
<ModelTester models={models} />
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
</AppPageShell>
|
</AppPageShell>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user