mirror of
https://github.com/memohai/Memoh.git
synced 2026-04-25 07:00:48 +09:00
feat(models): per-model probe testing with auto-detect UI (#133)
* feat(models): add per-model probe testing and auto-detect in UI Move health probes from provider level to model level for precise testing with real model_id and client_type. Provider test is now a simple reachability check. Backend: - Add POST /models/:id/test endpoint that probes the model's provider using its actual model_id and client_type - Add model healthcheck checker for bot health checks (chat/memory/embedding) - Simplify provider test to reachability-only Frontend: - Auto-probe models on mount with status indicator (green/yellow/red dot + latency) - Auto-probe provider reachability on load and on provider switch - Fix missing faBolt icon registration - Manual re-probe via refresh button Closes #117 * fix(models): increase probe timeout to 15s for slow providers Some providers (e.g. DashScope) exceed the 5s probe timeout, causing false-negative "context deadline exceeded" errors. Increase per-probe timeout to 15s and healthcheck overall timeout to 30s. * fix(sdk): regenerate exports after merge conflict Resolve duplicate SDK exports introduced by merge conflict resolution so the web build can compile again while preserving new model probe endpoints.
This commit is contained in:
+5
-1
@@ -42,6 +42,7 @@ import (
|
||||
"github.com/memohai/memoh/internal/healthcheck"
|
||||
channelchecker "github.com/memohai/memoh/internal/healthcheck/checkers/channel"
|
||||
mcpchecker "github.com/memohai/memoh/internal/healthcheck/checkers/mcp"
|
||||
modelchecker "github.com/memohai/memoh/internal/healthcheck/checkers/model"
|
||||
"github.com/memohai/memoh/internal/inbox"
|
||||
"github.com/memohai/memoh/internal/logger"
|
||||
"github.com/memohai/memoh/internal/mcp"
|
||||
@@ -666,7 +667,7 @@ func startContainerReconciliation(lc fx.Lifecycle, containerdHandler *handlers.C
|
||||
})
|
||||
}
|
||||
|
||||
func startServer(lc fx.Lifecycle, logger *slog.Logger, srv *server.Server, shutdowner fx.Shutdowner, cfg config.Config, queries *dbsqlc.Queries, botService *bots.Service, containerdHandler *handlers.ContainerdHandler, mcpConnService *mcp.ConnectionService, toolGateway *mcp.ToolGatewayService, channelManager *channel.Manager) {
|
||||
func startServer(lc fx.Lifecycle, logger *slog.Logger, srv *server.Server, shutdowner fx.Shutdowner, cfg config.Config, queries *dbsqlc.Queries, botService *bots.Service, containerdHandler *handlers.ContainerdHandler, mcpConnService *mcp.ConnectionService, toolGateway *mcp.ToolGatewayService, channelManager *channel.Manager, modelsService *models.Service) {
|
||||
fmt.Printf("Starting Memoh Agent %s\n", version.GetInfo())
|
||||
|
||||
lc.Append(fx.Hook{
|
||||
@@ -681,6 +682,9 @@ func startServer(lc fx.Lifecycle, logger *slog.Logger, srv *server.Server, shutd
|
||||
botService.AddRuntimeChecker(healthcheck.NewRuntimeCheckerAdapter(
|
||||
channelchecker.NewChecker(logger, channelManager),
|
||||
))
|
||||
botService.AddRuntimeChecker(healthcheck.NewRuntimeCheckerAdapter(
|
||||
modelchecker.NewChecker(logger, modelchecker.NewQueriesLookup(queries), modelsService),
|
||||
))
|
||||
|
||||
go func() {
|
||||
if err := srv.Start(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/labstack/echo/v4"
|
||||
@@ -35,6 +36,7 @@ func (h *ModelsHandler) Register(e *echo.Echo) {
|
||||
group.DELETE("/:id", h.DeleteByID)
|
||||
group.DELETE("/model/:modelId", h.DeleteByModelID)
|
||||
group.GET("/count", h.Count)
|
||||
group.POST("/:id/test", h.Test)
|
||||
}
|
||||
|
||||
// Create godoc
|
||||
@@ -280,6 +282,35 @@ func (h *ModelsHandler) DeleteByModelID(c echo.Context) error {
|
||||
return c.NoContent(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// Test godoc
|
||||
// @Summary Test model connectivity
|
||||
// @Description Probe a model's provider endpoint using the model's real model_id and client_type to verify configuration
|
||||
// @Tags models
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path string true "Model internal ID (UUID)"
|
||||
// @Success 200 {object} models.TestResponse
|
||||
// @Failure 400 {object} ErrorResponse
|
||||
// @Failure 404 {object} ErrorResponse
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Router /models/{id}/test [post]
|
||||
func (h *ModelsHandler) Test(c echo.Context) error {
|
||||
id := c.Param("id")
|
||||
if id == "" {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "id is required")
|
||||
}
|
||||
|
||||
resp, err := h.service.Test(c.Request().Context(), id)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "invalid") {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
|
||||
}
|
||||
return echo.NewHTTPError(http.StatusNotFound, err.Error())
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// Count godoc
|
||||
// @Summary Get model count
|
||||
// @Description Get the total count of models, optionally filtered by type
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
package modelchecker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/memohai/memoh/internal/healthcheck"
|
||||
"github.com/memohai/memoh/internal/models"
|
||||
)
|
||||
|
||||
const (
|
||||
checkTypeModelConnection = "model.connection"
|
||||
titleKeyModelConnection = "bots.checks.titles.modelConnection"
|
||||
defaultTimeout = 30 * time.Second
|
||||
)
|
||||
|
||||
// BotModelLookup fetches model IDs configured for a bot.
|
||||
type BotModelLookup interface {
|
||||
GetBotModelIDs(ctx context.Context, botID string) (BotModels, error)
|
||||
}
|
||||
|
||||
// BotModels holds the model UUIDs associated with a bot.
|
||||
type BotModels struct {
|
||||
ChatModelID string
|
||||
MemoryModelID string
|
||||
EmbeddingModelID string
|
||||
}
|
||||
|
||||
// ModelProber probes a model by its internal UUID.
|
||||
type ModelProber interface {
|
||||
Test(ctx context.Context, id string) (models.TestResponse, error)
|
||||
}
|
||||
|
||||
// Checker evaluates model connection health checks for a bot.
|
||||
type Checker struct {
|
||||
logger *slog.Logger
|
||||
lookup BotModelLookup
|
||||
prober ModelProber
|
||||
timeout time.Duration
|
||||
}
|
||||
|
||||
// NewChecker creates a model health checker.
|
||||
func NewChecker(log *slog.Logger, lookup BotModelLookup, prober ModelProber) *Checker {
|
||||
if log == nil {
|
||||
log = slog.Default()
|
||||
}
|
||||
return &Checker{
|
||||
logger: log.With(slog.String("checker", "healthcheck_model")),
|
||||
lookup: lookup,
|
||||
prober: prober,
|
||||
timeout: defaultTimeout,
|
||||
}
|
||||
}
|
||||
|
||||
type modelSlot struct {
|
||||
key string // "chat", "memory", "embedding"
|
||||
id string // model UUID
|
||||
label string // subtitle for display
|
||||
}
|
||||
|
||||
// ListChecks evaluates model health for a bot.
|
||||
func (c *Checker) ListChecks(ctx context.Context, botID string) []healthcheck.CheckResult {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
botID = strings.TrimSpace(botID)
|
||||
if botID == "" {
|
||||
return nil
|
||||
}
|
||||
if c.lookup == nil || c.prober == nil {
|
||||
c.logger.Warn("model healthcheck dependencies unavailable", slog.String("bot_id", botID))
|
||||
return []healthcheck.CheckResult{{
|
||||
ID: checkTypeModelConnection + ".service",
|
||||
Type: checkTypeModelConnection,
|
||||
TitleKey: titleKeyModelConnection,
|
||||
Status: healthcheck.StatusWarn,
|
||||
Summary: "Model checker service is not available.",
|
||||
}}
|
||||
}
|
||||
|
||||
botModels, err := c.lookup.GetBotModelIDs(ctx, botID)
|
||||
if err != nil {
|
||||
c.logger.Warn("model healthcheck lookup failed", slog.String("bot_id", botID), slog.Any("error", err))
|
||||
return []healthcheck.CheckResult{{
|
||||
ID: checkTypeModelConnection + ".lookup",
|
||||
Type: checkTypeModelConnection,
|
||||
TitleKey: titleKeyModelConnection,
|
||||
Status: healthcheck.StatusError,
|
||||
Summary: "Failed to look up bot model configuration.",
|
||||
Detail: err.Error(),
|
||||
}}
|
||||
}
|
||||
|
||||
var slots []modelSlot
|
||||
if botModels.ChatModelID != "" {
|
||||
slots = append(slots, modelSlot{key: "chat", id: botModels.ChatModelID, label: "Chat Model"})
|
||||
}
|
||||
if botModels.MemoryModelID != "" {
|
||||
slots = append(slots, modelSlot{key: "memory", id: botModels.MemoryModelID, label: "Memory Model"})
|
||||
}
|
||||
if botModels.EmbeddingModelID != "" {
|
||||
slots = append(slots, modelSlot{key: "embedding", id: botModels.EmbeddingModelID, label: "Embedding Model"})
|
||||
}
|
||||
if len(slots) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
probeCtx, cancel := context.WithTimeout(ctx, c.timeout)
|
||||
defer cancel()
|
||||
|
||||
results := make([]healthcheck.CheckResult, len(slots))
|
||||
var wg sync.WaitGroup
|
||||
for i, slot := range slots {
|
||||
wg.Add(1)
|
||||
go func(idx int, s modelSlot) {
|
||||
defer wg.Done()
|
||||
results[idx] = c.probeSlot(probeCtx, s)
|
||||
}(i, slot)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
func (c *Checker) probeSlot(ctx context.Context, s modelSlot) healthcheck.CheckResult {
|
||||
checkID := checkTypeModelConnection + "." + s.key
|
||||
result := healthcheck.CheckResult{
|
||||
ID: checkID,
|
||||
Type: checkTypeModelConnection,
|
||||
TitleKey: titleKeyModelConnection,
|
||||
Subtitle: s.label,
|
||||
Metadata: map[string]any{
|
||||
"model_id": s.id,
|
||||
"role": s.key,
|
||||
},
|
||||
}
|
||||
|
||||
resp, err := c.prober.Test(ctx, s.id)
|
||||
if err != nil {
|
||||
result.Status = healthcheck.StatusError
|
||||
result.Summary = fmt.Sprintf("%s is not reachable.", s.label)
|
||||
result.Detail = err.Error()
|
||||
return result
|
||||
}
|
||||
|
||||
switch resp.Status {
|
||||
case models.TestStatusOK:
|
||||
result.Status = healthcheck.StatusOK
|
||||
result.Summary = fmt.Sprintf("%s is healthy.", s.label)
|
||||
case models.TestStatusAuthError:
|
||||
result.Status = healthcheck.StatusError
|
||||
result.Summary = fmt.Sprintf("%s authentication failed.", s.label)
|
||||
result.Detail = resp.Message
|
||||
default:
|
||||
result.Status = healthcheck.StatusError
|
||||
result.Summary = fmt.Sprintf("%s probe failed.", s.label)
|
||||
result.Detail = resp.Message
|
||||
}
|
||||
|
||||
if resp.LatencyMs > 0 {
|
||||
result.Metadata["latency_ms"] = resp.LatencyMs
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package modelchecker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/memohai/memoh/internal/db"
|
||||
"github.com/memohai/memoh/internal/db/sqlc"
|
||||
)
|
||||
|
||||
// QueriesLookup adapts sqlc.Queries to the BotModelLookup interface.
|
||||
type QueriesLookup struct {
|
||||
queries *sqlc.Queries
|
||||
}
|
||||
|
||||
// NewQueriesLookup creates a BotModelLookup backed by sqlc.Queries.
|
||||
func NewQueriesLookup(queries *sqlc.Queries) *QueriesLookup {
|
||||
return &QueriesLookup{queries: queries}
|
||||
}
|
||||
|
||||
// GetBotModelIDs fetches the chat, memory, and embedding model IDs for a bot.
|
||||
func (l *QueriesLookup) GetBotModelIDs(ctx context.Context, botID string) (BotModels, error) {
|
||||
if strings.TrimSpace(botID) == "" {
|
||||
return BotModels{}, fmt.Errorf("bot id is required")
|
||||
}
|
||||
pgID, err := db.ParseUUID(botID)
|
||||
if err != nil {
|
||||
return BotModels{}, fmt.Errorf("invalid bot id: %w", err)
|
||||
}
|
||||
|
||||
bot, err := l.queries.GetBotByID(ctx, pgID)
|
||||
if err != nil {
|
||||
return BotModels{}, fmt.Errorf("get bot: %w", err)
|
||||
}
|
||||
|
||||
var m BotModels
|
||||
if bot.ChatModelID.Valid {
|
||||
m.ChatModelID = bot.ChatModelID.String()
|
||||
}
|
||||
if bot.MemoryModelID.Valid {
|
||||
m.MemoryModelID = bot.MemoryModelID.String()
|
||||
}
|
||||
if bot.EmbeddingModelID.Valid {
|
||||
m.EmbeddingModelID = bot.EmbeddingModelID.String()
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/memohai/memoh/internal/db"
|
||||
)
|
||||
|
||||
const probeTimeout = 15 * time.Second
|
||||
|
||||
// Test probes a model's provider endpoint using the model's real model_id
|
||||
// and client_type to verify that the configuration is valid.
|
||||
func (s *Service) Test(ctx context.Context, id string) (TestResponse, error) {
|
||||
modelID, err := db.ParseUUID(id)
|
||||
if err != nil {
|
||||
return TestResponse{}, fmt.Errorf("invalid model id: %w", err)
|
||||
}
|
||||
|
||||
model, err := s.queries.GetModelByID(ctx, modelID)
|
||||
if err != nil {
|
||||
return TestResponse{}, fmt.Errorf("get model: %w", err)
|
||||
}
|
||||
|
||||
provider, err := s.queries.GetLlmProviderByID(ctx, model.LlmProviderID)
|
||||
if err != nil {
|
||||
return TestResponse{}, fmt.Errorf("get provider: %w", err)
|
||||
}
|
||||
|
||||
baseURL := strings.TrimRight(provider.BaseUrl, "/")
|
||||
apiKey := provider.ApiKey
|
||||
|
||||
// Reachability check
|
||||
reachable, reachMsg := probeReachable(ctx, baseURL)
|
||||
if !reachable {
|
||||
return TestResponse{
|
||||
Status: TestStatusError,
|
||||
Message: reachMsg,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Select probe by client type (chat) or model type (embedding)
|
||||
var result probeResult
|
||||
if model.Type == string(ModelTypeEmbedding) {
|
||||
result = probeEmbedding(ctx, baseURL, apiKey, model.ModelID)
|
||||
} else {
|
||||
result = probeChatModel(ctx, baseURL, apiKey, model.ModelID, ClientType(model.ClientType.String))
|
||||
}
|
||||
|
||||
return TestResponse{
|
||||
Status: classifyProbe(result.statusCode),
|
||||
Reachable: true,
|
||||
LatencyMs: result.latencyMs,
|
||||
Message: result.message,
|
||||
}, nil
|
||||
}
|
||||
|
||||
type probeResult struct {
|
||||
statusCode int
|
||||
latencyMs int64
|
||||
message string
|
||||
}
|
||||
|
||||
func probeChatModel(ctx context.Context, baseURL, apiKey, modelID string, clientType ClientType) probeResult {
|
||||
switch clientType {
|
||||
case ClientTypeOpenAIResponses:
|
||||
body := fmt.Sprintf(`{"model":%q,"input":"hi","max_output_tokens":1}`, modelID)
|
||||
return doProbe(ctx, http.MethodPost, baseURL+"/responses", openAIHeaders(apiKey), body)
|
||||
|
||||
case ClientTypeOpenAICompletions:
|
||||
body := fmt.Sprintf(`{"model":%q,"messages":[{"role":"user","content":"hi"}],"max_tokens":1}`, modelID)
|
||||
return doProbe(ctx, http.MethodPost, baseURL+"/chat/completions", openAIHeaders(apiKey), body)
|
||||
|
||||
case ClientTypeAnthropicMessages:
|
||||
body := fmt.Sprintf(`{"model":%q,"messages":[{"role":"user","content":"hi"}],"max_tokens":1}`, modelID)
|
||||
return doProbe(ctx, http.MethodPost, baseURL+"/messages", map[string]string{
|
||||
"x-api-key": apiKey,
|
||||
"anthropic-version": "2023-06-01",
|
||||
"Content-Type": "application/json",
|
||||
}, body)
|
||||
|
||||
case ClientTypeGoogleGenerativeAI:
|
||||
body := `{"contents":[{"parts":[{"text":"hi"}]}],"generationConfig":{"maxOutputTokens":1}}`
|
||||
url := fmt.Sprintf("%s/models/%s:generateContent", baseURL, modelID)
|
||||
return doProbe(ctx, http.MethodPost, url, map[string]string{
|
||||
"x-goog-api-key": apiKey,
|
||||
"Content-Type": "application/json",
|
||||
}, body)
|
||||
|
||||
default:
|
||||
// Fallback: treat as OpenAI completions compatible
|
||||
body := fmt.Sprintf(`{"model":%q,"messages":[{"role":"user","content":"hi"}],"max_tokens":1}`, modelID)
|
||||
return doProbe(ctx, http.MethodPost, baseURL+"/chat/completions", openAIHeaders(apiKey), body)
|
||||
}
|
||||
}
|
||||
|
||||
func probeEmbedding(ctx context.Context, baseURL, apiKey, modelID string) probeResult {
|
||||
body := fmt.Sprintf(`{"model":%q,"input":"hello"}`, modelID)
|
||||
return doProbe(ctx, http.MethodPost, baseURL+"/embeddings", openAIHeaders(apiKey), body)
|
||||
}
|
||||
|
||||
func openAIHeaders(apiKey string) map[string]string {
|
||||
return map[string]string{
|
||||
"Authorization": "Bearer " + apiKey,
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
}
|
||||
|
||||
func probeReachable(ctx context.Context, baseURL string) (bool, string) {
|
||||
ctx, cancel := context.WithTimeout(ctx, probeTimeout)
|
||||
defer cancel()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, baseURL, nil)
|
||||
if err != nil {
|
||||
return false, err.Error()
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return false, err.Error()
|
||||
}
|
||||
io.Copy(io.Discard, resp.Body)
|
||||
resp.Body.Close()
|
||||
return true, ""
|
||||
}
|
||||
|
||||
func doProbe(ctx context.Context, method, url string, headers map[string]string, body string) probeResult {
|
||||
ctx, cancel := context.WithTimeout(ctx, probeTimeout)
|
||||
defer cancel()
|
||||
|
||||
var bodyReader io.Reader
|
||||
if body != "" {
|
||||
bodyReader = bytes.NewBufferString(body)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, method, url, bodyReader)
|
||||
if err != nil {
|
||||
return probeResult{message: err.Error()}
|
||||
}
|
||||
for k, v := range headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
latency := time.Since(start).Milliseconds()
|
||||
if err != nil {
|
||||
return probeResult{latencyMs: latency, message: err.Error()}
|
||||
}
|
||||
io.Copy(io.Discard, resp.Body)
|
||||
resp.Body.Close()
|
||||
|
||||
return probeResult{statusCode: resp.StatusCode, latencyMs: latency}
|
||||
}
|
||||
|
||||
func classifyProbe(statusCode int) TestStatus {
|
||||
switch {
|
||||
case statusCode >= 200 && statusCode <= 299:
|
||||
return TestStatusOK
|
||||
case statusCode == 400 || statusCode == 422 || statusCode == 429:
|
||||
// 400/422 = endpoint works but request rejected; 429 = rate limited (model exists)
|
||||
return TestStatusOK
|
||||
case statusCode == 401 || statusCode == 403:
|
||||
return TestStatusAuthError
|
||||
default:
|
||||
return TestStatusError
|
||||
}
|
||||
}
|
||||
@@ -138,3 +138,20 @@ type DeleteResponse struct {
|
||||
type CountResponse struct {
|
||||
Count int64 `json:"count"`
|
||||
}
|
||||
|
||||
// TestStatus represents the outcome of probing a model.
|
||||
type TestStatus string
|
||||
|
||||
const (
|
||||
TestStatusOK TestStatus = "ok"
|
||||
TestStatusAuthError TestStatus = "auth_error"
|
||||
TestStatusError TestStatus = "error"
|
||||
)
|
||||
|
||||
// TestResponse is returned by POST /models/:id/test.
|
||||
type TestResponse struct {
|
||||
Status TestStatus `json:"status"`
|
||||
Reachable bool `json:"reachable"`
|
||||
LatencyMs int64 `json:"latency_ms,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
}
|
||||
|
||||
+10
-146
@@ -1,7 +1,6 @@
|
||||
package providers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
@@ -9,7 +8,6 @@ import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/memohai/memoh/internal/db"
|
||||
@@ -165,8 +163,7 @@ func (s *Service) Count(ctx context.Context) (int64, error) {
|
||||
|
||||
const probeTimeout = 5 * time.Second
|
||||
|
||||
// Test probes the provider's base URL to check connectivity, supported
|
||||
// client types, and embedding support. All probes run concurrently.
|
||||
// Test probes the provider's base URL to check reachability.
|
||||
func (s *Service) Test(ctx context.Context, id string) (TestResponse, error) {
|
||||
providerID, err := db.ParseUUID(id)
|
||||
if err != nil {
|
||||
@@ -179,61 +176,16 @@ func (s *Service) Test(ctx context.Context, id string) (TestResponse, error) {
|
||||
}
|
||||
|
||||
baseURL := strings.TrimRight(provider.BaseUrl, "/")
|
||||
apiKey := provider.ApiKey
|
||||
|
||||
resp := TestResponse{Checks: make(map[string]CheckResult, 5)}
|
||||
|
||||
// Connectivity check
|
||||
start := time.Now()
|
||||
reachable, reachMsg := probeReachable(ctx, baseURL)
|
||||
resp.Reachable = reachable
|
||||
resp.LatencyMs = time.Since(start).Milliseconds()
|
||||
if !reachable {
|
||||
resp.Message = reachMsg
|
||||
return resp, nil
|
||||
}
|
||||
reachable, msg := probeReachable(ctx, baseURL)
|
||||
latency := time.Since(start).Milliseconds()
|
||||
|
||||
type namedResult struct {
|
||||
name string
|
||||
result CheckResult
|
||||
}
|
||||
|
||||
probes := []struct {
|
||||
name string
|
||||
fn func() CheckResult
|
||||
}{
|
||||
{"openai-completions", func() CheckResult {
|
||||
return probeOpenAICompletions(ctx, baseURL, apiKey)
|
||||
}},
|
||||
{"openai-responses", func() CheckResult {
|
||||
return probeOpenAIResponses(ctx, baseURL, apiKey)
|
||||
}},
|
||||
{"anthropic-messages", func() CheckResult {
|
||||
return probeAnthropicMessages(ctx, baseURL, apiKey)
|
||||
}},
|
||||
{"google-generative-ai", func() CheckResult {
|
||||
return probeGoogleGenerativeAI(ctx, baseURL, apiKey)
|
||||
}},
|
||||
{"embedding", func() CheckResult {
|
||||
return probeEmbedding(ctx, baseURL, apiKey)
|
||||
}},
|
||||
}
|
||||
|
||||
results := make([]namedResult, len(probes))
|
||||
var wg sync.WaitGroup
|
||||
for i, p := range probes {
|
||||
wg.Add(1)
|
||||
go func(idx int, name string, fn func() CheckResult) {
|
||||
defer wg.Done()
|
||||
results[idx] = namedResult{name: name, result: fn()}
|
||||
}(i, p.name, p.fn)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
for _, nr := range results {
|
||||
resp.Checks[nr.name] = nr.result
|
||||
}
|
||||
return resp, nil
|
||||
return TestResponse{
|
||||
Reachable: reachable,
|
||||
LatencyMs: latency,
|
||||
Message: msg,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func probeReachable(ctx context.Context, baseURL string) (bool, string) {
|
||||
@@ -244,101 +196,13 @@ func probeReachable(ctx context.Context, baseURL string) (bool, string) {
|
||||
if err != nil {
|
||||
return false, err.Error()
|
||||
}
|
||||
httpResp, err := http.DefaultClient.Do(req)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return false, err.Error()
|
||||
}
|
||||
io.Copy(io.Discard, httpResp.Body)
|
||||
httpResp.Body.Close()
|
||||
return true, ""
|
||||
}
|
||||
|
||||
func probeOpenAICompletions(ctx context.Context, baseURL, apiKey string) CheckResult {
|
||||
return probeEndpoint(ctx, http.MethodGet, baseURL+"/models",
|
||||
map[string]string{
|
||||
"Authorization": "Bearer " + apiKey,
|
||||
}, "")
|
||||
}
|
||||
|
||||
func probeOpenAIResponses(ctx context.Context, baseURL, apiKey string) CheckResult {
|
||||
body := `{"model":"probe-test","input":"hi","max_output_tokens":1}`
|
||||
return probeEndpoint(ctx, http.MethodPost, baseURL+"/responses",
|
||||
map[string]string{
|
||||
"Authorization": "Bearer " + apiKey,
|
||||
"Content-Type": "application/json",
|
||||
}, body)
|
||||
}
|
||||
|
||||
func probeAnthropicMessages(ctx context.Context, baseURL, apiKey string) CheckResult {
|
||||
body := `{"model":"probe-test","messages":[{"role":"user","content":"hi"}],"max_tokens":1}`
|
||||
return probeEndpoint(ctx, http.MethodPost, baseURL+"/messages",
|
||||
map[string]string{
|
||||
"x-api-key": apiKey,
|
||||
"anthropic-version": "2023-06-01",
|
||||
"Content-Type": "application/json",
|
||||
}, body)
|
||||
}
|
||||
|
||||
func probeGoogleGenerativeAI(ctx context.Context, baseURL, apiKey string) CheckResult {
|
||||
return probeEndpoint(ctx, http.MethodGet, baseURL+"/models",
|
||||
map[string]string{
|
||||
"x-goog-api-key": apiKey,
|
||||
}, "")
|
||||
}
|
||||
|
||||
func probeEmbedding(ctx context.Context, baseURL, apiKey string) CheckResult {
|
||||
body := `{"model":"probe-test","input":"hello"}`
|
||||
return probeEndpoint(ctx, http.MethodPost, baseURL+"/embeddings",
|
||||
map[string]string{
|
||||
"Authorization": "Bearer " + apiKey,
|
||||
"Content-Type": "application/json",
|
||||
}, body)
|
||||
}
|
||||
|
||||
func probeEndpoint(ctx context.Context, method, url string, headers map[string]string, body string) CheckResult {
|
||||
ctx, cancel := context.WithTimeout(ctx, probeTimeout)
|
||||
defer cancel()
|
||||
|
||||
var bodyReader io.Reader
|
||||
if body != "" {
|
||||
bodyReader = bytes.NewBufferString(body)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, method, url, bodyReader)
|
||||
if err != nil {
|
||||
return CheckResult{Status: CheckStatusError, Message: err.Error()}
|
||||
}
|
||||
for k, v := range headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
latency := time.Since(start).Milliseconds()
|
||||
if err != nil {
|
||||
return CheckResult{Status: CheckStatusError, LatencyMs: latency, Message: err.Error()}
|
||||
}
|
||||
io.Copy(io.Discard, resp.Body)
|
||||
resp.Body.Close()
|
||||
|
||||
return classifyResponse(resp.StatusCode, latency)
|
||||
}
|
||||
|
||||
func classifyResponse(statusCode int, latencyMs int64) CheckResult {
|
||||
r := CheckResult{StatusCode: statusCode, LatencyMs: latencyMs}
|
||||
switch {
|
||||
case statusCode >= 200 && statusCode <= 299,
|
||||
statusCode == 400, statusCode == 422, statusCode == 429:
|
||||
r.Status = CheckStatusSupported
|
||||
case statusCode == 401 || statusCode == 403:
|
||||
r.Status = CheckStatusAuthError
|
||||
case statusCode == 404 || statusCode == 405:
|
||||
r.Status = CheckStatusUnsupported
|
||||
default:
|
||||
r.Status = CheckStatusError
|
||||
r.Message = fmt.Sprintf("unexpected status %d", statusCode)
|
||||
}
|
||||
return r
|
||||
return true, ""
|
||||
}
|
||||
|
||||
// toGetResponse converts a database provider to a response
|
||||
|
||||
@@ -40,28 +40,9 @@ type CountResponse struct {
|
||||
Count int64 `json:"count"`
|
||||
}
|
||||
|
||||
// CheckStatus represents the result status of a single probe check.
|
||||
type CheckStatus string
|
||||
|
||||
const (
|
||||
CheckStatusSupported CheckStatus = "supported"
|
||||
CheckStatusAuthError CheckStatus = "auth_error"
|
||||
CheckStatusUnsupported CheckStatus = "unsupported"
|
||||
CheckStatusError CheckStatus = "error"
|
||||
)
|
||||
|
||||
// CheckResult holds the outcome of probing a single endpoint.
|
||||
type CheckResult struct {
|
||||
Status CheckStatus `json:"status"`
|
||||
StatusCode int `json:"status_code,omitempty"`
|
||||
LatencyMs int64 `json:"latency_ms,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
}
|
||||
|
||||
// TestResponse is returned by POST /providers/:id/test.
|
||||
type TestResponse struct {
|
||||
Reachable bool `json:"reachable"`
|
||||
LatencyMs int64 `json:"latency_ms,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
Checks map[string]CheckResult `json:"checks"`
|
||||
Reachable bool `json:"reachable"`
|
||||
LatencyMs int64 `json:"latency_ms,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -964,6 +964,15 @@ export type ModelsGetResponse = {
|
||||
|
||||
export type ModelsModelType = 'chat' | 'embedding';
|
||||
|
||||
export type ModelsTestResponse = {
|
||||
latency_ms?: number;
|
||||
message?: string;
|
||||
reachable?: boolean;
|
||||
status?: ModelsTestStatus;
|
||||
};
|
||||
|
||||
export type ModelsTestStatus = 'ok' | 'auth_error' | 'error';
|
||||
|
||||
export type ModelsUpdateRequest = {
|
||||
client_type?: ModelsClientType;
|
||||
dimensions?: number;
|
||||
@@ -975,15 +984,6 @@ export type ModelsUpdateRequest = {
|
||||
type?: ModelsModelType;
|
||||
};
|
||||
|
||||
export type ProvidersCheckResult = {
|
||||
latency_ms?: number;
|
||||
message?: string;
|
||||
status?: ProvidersCheckStatus;
|
||||
status_code?: number;
|
||||
};
|
||||
|
||||
export type ProvidersCheckStatus = 'supported' | 'auth_error' | 'unsupported' | 'error';
|
||||
|
||||
export type ProvidersCountResponse = {
|
||||
count?: number;
|
||||
};
|
||||
@@ -1013,9 +1013,6 @@ export type ProvidersGetResponse = {
|
||||
};
|
||||
|
||||
export type ProvidersTestResponse = {
|
||||
checks?: {
|
||||
[key: string]: ProvidersCheckResult;
|
||||
};
|
||||
latency_ms?: number;
|
||||
message?: string;
|
||||
reachable?: boolean;
|
||||
@@ -5699,6 +5696,44 @@ export type PutModelsByIdResponses = {
|
||||
|
||||
export type PutModelsByIdResponse = PutModelsByIdResponses[keyof PutModelsByIdResponses];
|
||||
|
||||
export type PostModelsByIdTestData = {
|
||||
body?: never;
|
||||
path: {
|
||||
/**
|
||||
* Model internal ID (UUID)
|
||||
*/
|
||||
id: string;
|
||||
};
|
||||
query?: never;
|
||||
url: '/models/{id}/test';
|
||||
};
|
||||
|
||||
export type PostModelsByIdTestErrors = {
|
||||
/**
|
||||
* Bad Request
|
||||
*/
|
||||
400: HandlersErrorResponse;
|
||||
/**
|
||||
* Not Found
|
||||
*/
|
||||
404: HandlersErrorResponse;
|
||||
/**
|
||||
* Internal Server Error
|
||||
*/
|
||||
500: HandlersErrorResponse;
|
||||
};
|
||||
|
||||
export type PostModelsByIdTestError = PostModelsByIdTestErrors[keyof PostModelsByIdTestErrors];
|
||||
|
||||
export type PostModelsByIdTestResponses = {
|
||||
/**
|
||||
* OK
|
||||
*/
|
||||
200: ModelsTestResponse;
|
||||
};
|
||||
|
||||
export type PostModelsByIdTestResponse = PostModelsByIdTestResponses[keyof PostModelsByIdTestResponses];
|
||||
|
||||
export type GetPingData = {
|
||||
body?: never;
|
||||
path?: never;
|
||||
|
||||
@@ -10,17 +10,17 @@ export const CLIENT_TYPE_META: Record<string, ClientTypeMeta> = {
|
||||
'openai-responses': {
|
||||
value: 'openai-responses',
|
||||
label: 'OpenAI Responses',
|
||||
hint: '/v1/responses',
|
||||
hint: 'Responses API (streaming, built-in tools)',
|
||||
},
|
||||
'openai-completions': {
|
||||
value: 'openai-completions',
|
||||
label: 'OpenAI Completions',
|
||||
hint: '/v1/models',
|
||||
hint: 'Chat Completions API (widely compatible)',
|
||||
},
|
||||
'anthropic-messages': {
|
||||
value: 'anthropic-messages',
|
||||
label: 'Anthropic Messages',
|
||||
hint: '/v1/messages',
|
||||
hint: 'Messages API (Claude models)',
|
||||
},
|
||||
'google-generative-ai': {
|
||||
value: 'google-generative-ai',
|
||||
|
||||
@@ -163,7 +163,12 @@
|
||||
"audio": "Audio",
|
||||
"video": "Video",
|
||||
"file": "File"
|
||||
}
|
||||
},
|
||||
"testModel": "Test Model",
|
||||
"testOk": "OK",
|
||||
"testAuthError": "Auth Error",
|
||||
"testError": "Error",
|
||||
"testFailed": "Test failed"
|
||||
},
|
||||
"provider": {
|
||||
"add": "Add Provider",
|
||||
@@ -179,11 +184,6 @@
|
||||
"testConnection": "Test Connection",
|
||||
"reachable": "Reachable",
|
||||
"unreachable": "Unreachable",
|
||||
"supported": "Supported",
|
||||
"authError": "Auth Error",
|
||||
"unsupported": "Unsupported",
|
||||
"error": "Error",
|
||||
"embedding": "Embedding",
|
||||
"testFailed": "Test failed"
|
||||
},
|
||||
"searchProvider": {
|
||||
|
||||
@@ -159,7 +159,12 @@
|
||||
"audio": "音频",
|
||||
"video": "视频",
|
||||
"file": "文件"
|
||||
}
|
||||
},
|
||||
"testModel": "测试模型",
|
||||
"testOk": "正常",
|
||||
"testAuthError": "认证失败",
|
||||
"testError": "异常",
|
||||
"testFailed": "测试失败"
|
||||
},
|
||||
"provider": {
|
||||
"add": "添加服务商",
|
||||
@@ -175,11 +180,6 @@
|
||||
"testConnection": "测试连接",
|
||||
"reachable": "可连接",
|
||||
"unreachable": "不可连接",
|
||||
"supported": "支持",
|
||||
"authError": "认证失败",
|
||||
"unsupported": "不支持",
|
||||
"error": "错误",
|
||||
"embedding": "Embedding",
|
||||
"testFailed": "测试失败"
|
||||
},
|
||||
"searchProvider": {
|
||||
|
||||
@@ -52,6 +52,7 @@ import {
|
||||
faFile,
|
||||
faMusic,
|
||||
faVideo,
|
||||
faBolt,
|
||||
faEnvelope,
|
||||
faChartLine,
|
||||
faFolderOpen,
|
||||
@@ -101,6 +102,7 @@ library.add(
|
||||
faFile,
|
||||
faMusic,
|
||||
faVideo,
|
||||
faBolt,
|
||||
faRectangleList,
|
||||
faTrashCan,
|
||||
faComments,
|
||||
|
||||
@@ -1,7 +1,23 @@
|
||||
<template>
|
||||
<Item variant="outline">
|
||||
<ItemContent>
|
||||
<ItemTitle>{{ model.name || model.model_id }}</ItemTitle>
|
||||
<ItemTitle class="flex items-center gap-2">
|
||||
{{ model.name || model.model_id }}
|
||||
<span
|
||||
v-if="testResult"
|
||||
class="inline-flex items-center gap-1.5 text-xs text-muted-foreground"
|
||||
>
|
||||
<span
|
||||
class="inline-block size-2 rounded-full"
|
||||
:class="statusDotClass"
|
||||
/>
|
||||
<span v-if="testResult.latency_ms">{{ testResult.latency_ms }}ms</span>
|
||||
</span>
|
||||
<Spinner
|
||||
v-if="testLoading"
|
||||
class="size-3.5"
|
||||
/>
|
||||
</ItemTitle>
|
||||
<ItemDescription class="gap-2 flex flex-wrap items-center mt-3">
|
||||
<Badge variant="outline">
|
||||
{{ model.type }}
|
||||
@@ -12,9 +28,26 @@
|
||||
>
|
||||
{{ model.client_type }}
|
||||
</Badge>
|
||||
<span
|
||||
v-if="testResult && testResult.status !== 'ok' && testResult.message"
|
||||
class="text-destructive text-xs"
|
||||
>
|
||||
{{ testResult.message }}
|
||||
</span>
|
||||
</ItemDescription>
|
||||
</ItemContent>
|
||||
<ItemActions>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
class="cursor-pointer"
|
||||
:disabled="testLoading"
|
||||
:aria-label="$t('models.testModel')"
|
||||
@click="runTest"
|
||||
>
|
||||
<FontAwesomeIcon :icon="['fas', 'rotate']" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
@@ -53,11 +86,14 @@ import {
|
||||
ItemTitle,
|
||||
Badge,
|
||||
Button,
|
||||
Spinner,
|
||||
} from '@memoh/ui'
|
||||
import ConfirmPopover from '@/components/confirm-popover/index.vue'
|
||||
import type { ModelsGetResponse } from '@memoh/sdk'
|
||||
import { postModelsByIdTest } from '@memoh/sdk'
|
||||
import type { ModelsGetResponse, ModelsTestResponse } from '@memoh/sdk'
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
|
||||
defineProps<{
|
||||
const props = defineProps<{
|
||||
model: ModelsGetResponse
|
||||
deleteLoading: boolean
|
||||
}>()
|
||||
@@ -66,4 +102,37 @@ defineEmits<{
|
||||
edit: [model: ModelsGetResponse]
|
||||
delete: [id: string]
|
||||
}>()
|
||||
|
||||
const testLoading = ref(false)
|
||||
const testResult = ref<ModelsTestResponse | null>(null)
|
||||
|
||||
const statusDotClass = computed(() => {
|
||||
switch (testResult.value?.status) {
|
||||
case 'ok': return 'bg-green-500'
|
||||
case 'auth_error': return 'bg-yellow-500'
|
||||
case 'error': return 'bg-red-500'
|
||||
default: return 'bg-gray-400'
|
||||
}
|
||||
})
|
||||
|
||||
async function runTest() {
|
||||
if (!props.model.id) return
|
||||
testLoading.value = true
|
||||
testResult.value = null
|
||||
try {
|
||||
const { data } = await postModelsByIdTest({
|
||||
path: { id: props.model.id },
|
||||
throwOnError: true,
|
||||
})
|
||||
testResult.value = data ?? null
|
||||
} catch {
|
||||
testResult.value = { status: 'error' }
|
||||
} finally {
|
||||
testLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
runTest()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -73,6 +73,11 @@
|
||||
:disabled="!props.provider?.id"
|
||||
@click="runTest"
|
||||
>
|
||||
<Spinner v-if="testLoading" />
|
||||
<FontAwesomeIcon
|
||||
v-else
|
||||
:icon="['fas', 'rotate']"
|
||||
/>
|
||||
{{ $t('provider.testConnection') }}
|
||||
</LoadingButton>
|
||||
|
||||
@@ -120,25 +125,12 @@
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<template v-if="testResult.reachable && testResult.checks">
|
||||
<div
|
||||
v-for="key in clientTypeKeys"
|
||||
:key="key"
|
||||
class="flex items-center justify-between"
|
||||
>
|
||||
<span>{{ clientTypeLabel(key) }}</span>
|
||||
<Badge :variant="statusVariant(testResult.checks[key]?.status)">
|
||||
{{ statusText(testResult.checks[key]?.status) }}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<span>{{ $t('provider.embedding') }}</span>
|
||||
<Badge :variant="statusVariant(testResult.checks['embedding']?.status)">
|
||||
{{ statusText(testResult.checks['embedding']?.status) }}
|
||||
</Badge>
|
||||
</div>
|
||||
</template>
|
||||
<div
|
||||
v-if="testResult.message"
|
||||
class="text-muted-foreground text-xs"
|
||||
>
|
||||
{{ testResult.message }}
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="testError"
|
||||
@@ -154,7 +146,6 @@
|
||||
import {
|
||||
Input,
|
||||
Button,
|
||||
Badge,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
@@ -168,9 +159,8 @@ import { toTypedSchema } from '@vee-validate/zod'
|
||||
import z from 'zod'
|
||||
import { useForm } from 'vee-validate'
|
||||
import { postProvidersByIdTest } from '@memoh/sdk'
|
||||
import type { ProvidersGetResponse, ProvidersTestResponse, ProvidersCheckStatus } from '@memoh/sdk'
|
||||
import type { ProvidersGetResponse, ProvidersTestResponse } from '@memoh/sdk'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { CLIENT_TYPE_META } from '@/constants/client-types'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
@@ -189,32 +179,6 @@ const testLoading = ref(false)
|
||||
const testResult = ref<ProvidersTestResponse | null>(null)
|
||||
const testError = ref('')
|
||||
|
||||
const clientTypeKeys = ['openai-completions', 'openai-responses', 'anthropic-messages', 'google-generative-ai']
|
||||
|
||||
function clientTypeLabel(key: string): string {
|
||||
return CLIENT_TYPE_META[key]?.label ?? key
|
||||
}
|
||||
|
||||
function statusVariant(status?: ProvidersCheckStatus): 'default' | 'secondary' | 'destructive' | 'outline' {
|
||||
switch (status) {
|
||||
case 'supported': return 'default'
|
||||
case 'auth_error': return 'secondary'
|
||||
case 'unsupported': return 'outline'
|
||||
case 'error': return 'destructive'
|
||||
default: return 'outline'
|
||||
}
|
||||
}
|
||||
|
||||
function statusText(status?: ProvidersCheckStatus): string {
|
||||
switch (status) {
|
||||
case 'supported': return t('provider.supported')
|
||||
case 'auth_error': return t('provider.authError')
|
||||
case 'unsupported': return t('provider.unsupported')
|
||||
case 'error': return t('provider.error')
|
||||
default: return '-'
|
||||
}
|
||||
}
|
||||
|
||||
async function runTest() {
|
||||
if (!props.provider?.id) return
|
||||
testLoading.value = true
|
||||
@@ -233,6 +197,10 @@ async function runTest() {
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => props.provider?.id, (newId) => {
|
||||
if (newId) runTest()
|
||||
}, { immediate: true })
|
||||
|
||||
const providerSchema = toTypedSchema(z.object({
|
||||
name: z.string().min(1),
|
||||
base_url: z.string().min(1),
|
||||
|
||||
+80
-38
@@ -5457,6 +5457,56 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"/models/{id}/test": {
|
||||
"post": {
|
||||
"description": "Probe a model's provider endpoint using the model's real model_id and client_type to verify configuration",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"models"
|
||||
],
|
||||
"summary": "Test model connectivity",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Model internal ID (UUID)",
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/models.TestResponse"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/handlers.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Not Found",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/handlers.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/handlers.ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/ping": {
|
||||
"get": {
|
||||
"tags": [
|
||||
@@ -8984,6 +9034,36 @@ const docTemplate = `{
|
||||
"ModelTypeEmbedding"
|
||||
]
|
||||
},
|
||||
"models.TestResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"latency_ms": {
|
||||
"type": "integer"
|
||||
},
|
||||
"message": {
|
||||
"type": "string"
|
||||
},
|
||||
"reachable": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"status": {
|
||||
"$ref": "#/definitions/models.TestStatus"
|
||||
}
|
||||
}
|
||||
},
|
||||
"models.TestStatus": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"ok",
|
||||
"auth_error",
|
||||
"error"
|
||||
],
|
||||
"x-enum-varnames": [
|
||||
"TestStatusOK",
|
||||
"TestStatusAuthError",
|
||||
"TestStatusError"
|
||||
]
|
||||
},
|
||||
"models.UpdateRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -9016,38 +9096,6 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"providers.CheckResult": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"latency_ms": {
|
||||
"type": "integer"
|
||||
},
|
||||
"message": {
|
||||
"type": "string"
|
||||
},
|
||||
"status": {
|
||||
"$ref": "#/definitions/providers.CheckStatus"
|
||||
},
|
||||
"status_code": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
},
|
||||
"providers.CheckStatus": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"supported",
|
||||
"auth_error",
|
||||
"unsupported",
|
||||
"error"
|
||||
],
|
||||
"x-enum-varnames": [
|
||||
"CheckStatusSupported",
|
||||
"CheckStatusAuthError",
|
||||
"CheckStatusUnsupported",
|
||||
"CheckStatusError"
|
||||
]
|
||||
},
|
||||
"providers.CountResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -9109,12 +9157,6 @@ const docTemplate = `{
|
||||
"providers.TestResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"checks": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"$ref": "#/definitions/providers.CheckResult"
|
||||
}
|
||||
},
|
||||
"latency_ms": {
|
||||
"type": "integer"
|
||||
},
|
||||
|
||||
+80
-38
@@ -5448,6 +5448,56 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/models/{id}/test": {
|
||||
"post": {
|
||||
"description": "Probe a model's provider endpoint using the model's real model_id and client_type to verify configuration",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"models"
|
||||
],
|
||||
"summary": "Test model connectivity",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Model internal ID (UUID)",
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/models.TestResponse"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/handlers.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Not Found",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/handlers.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/handlers.ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/ping": {
|
||||
"get": {
|
||||
"tags": [
|
||||
@@ -8975,6 +9025,36 @@
|
||||
"ModelTypeEmbedding"
|
||||
]
|
||||
},
|
||||
"models.TestResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"latency_ms": {
|
||||
"type": "integer"
|
||||
},
|
||||
"message": {
|
||||
"type": "string"
|
||||
},
|
||||
"reachable": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"status": {
|
||||
"$ref": "#/definitions/models.TestStatus"
|
||||
}
|
||||
}
|
||||
},
|
||||
"models.TestStatus": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"ok",
|
||||
"auth_error",
|
||||
"error"
|
||||
],
|
||||
"x-enum-varnames": [
|
||||
"TestStatusOK",
|
||||
"TestStatusAuthError",
|
||||
"TestStatusError"
|
||||
]
|
||||
},
|
||||
"models.UpdateRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -9007,38 +9087,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"providers.CheckResult": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"latency_ms": {
|
||||
"type": "integer"
|
||||
},
|
||||
"message": {
|
||||
"type": "string"
|
||||
},
|
||||
"status": {
|
||||
"$ref": "#/definitions/providers.CheckStatus"
|
||||
},
|
||||
"status_code": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
},
|
||||
"providers.CheckStatus": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"supported",
|
||||
"auth_error",
|
||||
"unsupported",
|
||||
"error"
|
||||
],
|
||||
"x-enum-varnames": [
|
||||
"CheckStatusSupported",
|
||||
"CheckStatusAuthError",
|
||||
"CheckStatusUnsupported",
|
||||
"CheckStatusError"
|
||||
]
|
||||
},
|
||||
"providers.CountResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -9100,12 +9148,6 @@
|
||||
"providers.TestResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"checks": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"$ref": "#/definitions/providers.CheckResult"
|
||||
}
|
||||
},
|
||||
"latency_ms": {
|
||||
"type": "integer"
|
||||
},
|
||||
|
||||
+55
-27
@@ -1583,6 +1583,27 @@ definitions:
|
||||
x-enum-varnames:
|
||||
- ModelTypeChat
|
||||
- ModelTypeEmbedding
|
||||
models.TestResponse:
|
||||
properties:
|
||||
latency_ms:
|
||||
type: integer
|
||||
message:
|
||||
type: string
|
||||
reachable:
|
||||
type: boolean
|
||||
status:
|
||||
$ref: '#/definitions/models.TestStatus'
|
||||
type: object
|
||||
models.TestStatus:
|
||||
enum:
|
||||
- ok
|
||||
- auth_error
|
||||
- error
|
||||
type: string
|
||||
x-enum-varnames:
|
||||
- TestStatusOK
|
||||
- TestStatusAuthError
|
||||
- TestStatusError
|
||||
models.UpdateRequest:
|
||||
properties:
|
||||
client_type:
|
||||
@@ -1604,29 +1625,6 @@ definitions:
|
||||
type:
|
||||
$ref: '#/definitions/models.ModelType'
|
||||
type: object
|
||||
providers.CheckResult:
|
||||
properties:
|
||||
latency_ms:
|
||||
type: integer
|
||||
message:
|
||||
type: string
|
||||
status:
|
||||
$ref: '#/definitions/providers.CheckStatus'
|
||||
status_code:
|
||||
type: integer
|
||||
type: object
|
||||
providers.CheckStatus:
|
||||
enum:
|
||||
- supported
|
||||
- auth_error
|
||||
- unsupported
|
||||
- error
|
||||
type: string
|
||||
x-enum-varnames:
|
||||
- CheckStatusSupported
|
||||
- CheckStatusAuthError
|
||||
- CheckStatusUnsupported
|
||||
- CheckStatusError
|
||||
providers.CountResponse:
|
||||
properties:
|
||||
count:
|
||||
@@ -1668,10 +1666,6 @@ definitions:
|
||||
type: object
|
||||
providers.TestResponse:
|
||||
properties:
|
||||
checks:
|
||||
additionalProperties:
|
||||
$ref: '#/definitions/providers.CheckResult'
|
||||
type: object
|
||||
latency_ms:
|
||||
type: integer
|
||||
message:
|
||||
@@ -5545,6 +5539,40 @@ paths:
|
||||
summary: Update model by internal ID
|
||||
tags:
|
||||
- models
|
||||
/models/{id}/test:
|
||||
post:
|
||||
consumes:
|
||||
- application/json
|
||||
description: Probe a model's provider endpoint using the model's real model_id
|
||||
and client_type to verify configuration
|
||||
parameters:
|
||||
- description: Model internal ID (UUID)
|
||||
in: path
|
||||
name: id
|
||||
required: true
|
||||
type: string
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
$ref: '#/definitions/models.TestResponse'
|
||||
"400":
|
||||
description: Bad Request
|
||||
schema:
|
||||
$ref: '#/definitions/handlers.ErrorResponse'
|
||||
"404":
|
||||
description: Not Found
|
||||
schema:
|
||||
$ref: '#/definitions/handlers.ErrorResponse'
|
||||
"500":
|
||||
description: Internal Server Error
|
||||
schema:
|
||||
$ref: '#/definitions/handlers.ErrorResponse'
|
||||
summary: Test model connectivity
|
||||
tags:
|
||||
- models
|
||||
/models/count:
|
||||
get:
|
||||
description: Get the total count of models, optionally filtered by type
|
||||
|
||||
Reference in New Issue
Block a user