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:
BBQ
2026-03-02 14:59:15 +08:00
committed by GitHub
parent cfb5f660bc
commit f9f968f13f
21 changed files with 850 additions and 355 deletions
+5 -1
View File
@@ -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) {
+31
View File
@@ -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
}
+172
View File
@@ -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
}
}
+17
View File
@@ -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
View File
@@ -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
+3 -22
View File
@@ -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
+47 -12
View File
@@ -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;
+3 -3
View File
@@ -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',
+6 -6
View File
@@ -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": {
+6 -6
View File
@@ -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": {
+2
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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