feat: connection test of provider

This commit is contained in:
Acbox
2026-02-18 18:52:26 +08:00
parent d6c47472b2
commit 1294b4cf75
13 changed files with 747 additions and 35 deletions
+30
View File
@@ -35,6 +35,7 @@ func (h *ProvidersHandler) Register(e *echo.Echo) {
group.PUT("/:id", h.Update)
group.DELETE("/:id", h.Delete)
group.GET("/count", h.Count)
group.POST("/:id/test", h.Test)
}
// Create godoc
@@ -252,3 +253,32 @@ func (h *ProvidersHandler) Count(c echo.Context) error {
return c.JSON(http.StatusOK, providers.CountResponse{Count: count})
}
// Test godoc
// @Summary Test provider connectivity
// @Description Probe a provider's base URL to check reachability, supported client types, and embedding support
// @Tags providers
// @Accept json
// @Produce json
// @Param id path string true "Provider ID (UUID)"
// @Success 200 {object} providers.TestResponse
// @Failure 400 {object} ErrorResponse
// @Failure 404 {object} ErrorResponse
// @Failure 500 {object} ErrorResponse
// @Router /providers/{id}/test [post]
func (h *ProvidersHandler) 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)
}
+185
View File
@@ -1,11 +1,16 @@
package providers
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"strings"
"sync"
"time"
"github.com/memohai/memoh/internal/db"
"github.com/memohai/memoh/internal/db/sqlc"
@@ -158,6 +163,186 @@ func (s *Service) Count(ctx context.Context) (int64, error) {
return count, nil
}
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.
func (s *Service) Test(ctx context.Context, id string) (TestResponse, error) {
providerID, err := db.ParseUUID(id)
if err != nil {
return TestResponse{}, err
}
provider, err := s.queries.GetLlmProviderByID(ctx, providerID)
if err != nil {
return TestResponse{}, fmt.Errorf("get provider: %w", err)
}
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
}
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
}
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()
}
httpResp, 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 {
body := `{"model":"probe-test","messages":[{"role":"user","content":"hi"}],"max_tokens":1}`
return probeEndpoint(ctx, http.MethodPost, baseURL+"/chat/completions",
map[string]string{
"Authorization": "Bearer " + apiKey,
"Content-Type": "application/json",
}, body)
}
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
}
// toGetResponse converts a database provider to a response
func (s *Service) toGetResponse(provider sqlc.LlmProvider) GetResponse {
var metadata map[string]any
+21 -9
View File
@@ -40,16 +40,28 @@ type CountResponse struct {
Count int64 `json:"count"`
}
// TestRequest represents a request to test provider connection
type TestRequest struct {
BaseURL string `json:"base_url" validate:"required,url"`
APIKey string `json:"api_key"`
Model string `json:"model"` // optional test model
// 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 represents the result of testing a provider
// TestResponse is returned by POST /providers/:id/test.
type TestResponse struct {
Success bool `json:"success"`
Message string `json:"message,omitempty"`
Latency int64 `json:"latency_ms,omitempty"` // latency in milliseconds
Reachable bool `json:"reachable"`
LatencyMs int64 `json:"latency_ms,omitempty"`
Message string `json:"message,omitempty"`
Checks map[string]CheckResult `json:"checks"`
}
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
+56
View File
@@ -733,6 +733,15 @@ 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;
};
@@ -761,6 +770,15 @@ export type ProvidersGetResponse = {
updated_at?: string;
};
export type ProvidersTestResponse = {
checks?: {
[key: string]: ProvidersCheckResult;
};
latency_ms?: number;
message?: string;
reachable?: boolean;
};
export type ProvidersUpdateRequest = {
api_key?: string;
base_url?: string;
@@ -4274,6 +4292,44 @@ export type GetProvidersByIdModelsResponses = {
export type GetProvidersByIdModelsResponse = GetProvidersByIdModelsResponses[keyof GetProvidersByIdModelsResponses];
export type PostProvidersByIdTestData = {
body?: never;
path: {
/**
* Provider ID (UUID)
*/
id: string;
};
query?: never;
url: '/providers/{id}/test';
};
export type PostProvidersByIdTestErrors = {
/**
* Bad Request
*/
400: HandlersErrorResponse;
/**
* Not Found
*/
404: HandlersErrorResponse;
/**
* Internal Server Error
*/
500: HandlersErrorResponse;
};
export type PostProvidersByIdTestError = PostProvidersByIdTestErrors[keyof PostProvidersByIdTestErrors];
export type PostProvidersByIdTestResponses = {
/**
* OK
*/
200: ProvidersTestResponse;
};
export type PostProvidersByIdTestResponse = PostProvidersByIdTestResponses[keyof PostProvidersByIdTestResponses];
export type GetSearchProvidersData = {
body?: never;
path?: never;
+10 -1
View File
@@ -171,7 +171,16 @@
"deleteConfirm": "Are you sure you want to delete this provider?",
"saveChanges": "Save Changes",
"emptyTitle": "No Providers",
"emptyDescription": "Add a model provider first to configure models"
"emptyDescription": "Add a model provider first to configure models",
"testConnection": "Test Connection",
"reachable": "Reachable",
"unreachable": "Unreachable",
"supported": "Supported",
"authError": "Auth Error",
"unsupported": "Unsupported",
"error": "Error",
"embedding": "Embedding",
"testFailed": "Test failed"
},
"searchProvider": {
"title": "Search Providers",
+10 -1
View File
@@ -167,7 +167,16 @@
"deleteConfirm": "确定要删除这个服务商吗?",
"saveChanges": "保存修改",
"emptyTitle": "暂无服务商",
"emptyDescription": "请先添加模型服务商,才能配置模型"
"emptyDescription": "请先添加模型服务商,才能配置模型",
"testConnection": "测试连接",
"reachable": "可连接",
"unreachable": "不可连接",
"supported": "支持",
"authError": "认证失败",
"unsupported": "不支持",
"error": "错误",
"embedding": "Embedding",
"testFailed": "测试失败"
},
"searchProvider": {
"title": "搜索提供方",
@@ -62,26 +62,86 @@
</section>
</div>
<section class="flex justify-end mt-4 gap-4">
<ConfirmPopover
:message="$t('provider.deleteConfirm')"
:loading="deleteLoading"
@confirm="$emit('delete')"
>
<template #trigger>
<Button variant="outline">
<FontAwesomeIcon :icon="['far', 'trash-can']" />
</Button>
</template>
</ConfirmPopover>
<section class="flex justify-between items-center mt-4">
<Button
type="submit"
:disabled="!hasChanges || !form.meta.value.valid"
type="button"
variant="outline"
:disabled="testLoading || !props.provider?.id"
@click="runTest"
>
<Spinner v-if="editLoading" />
{{ $t('provider.saveChanges') }}
<Spinner v-if="testLoading" />
{{ $t('provider.testConnection') }}
</Button>
<div class="flex gap-4">
<ConfirmPopover
:message="$t('provider.deleteConfirm')"
:loading="deleteLoading"
@confirm="$emit('delete')"
>
<template #trigger>
<Button variant="outline">
<FontAwesomeIcon :icon="['far', 'trash-can']" />
</Button>
</template>
</ConfirmPopover>
<Button
type="submit"
:disabled="!hasChanges || !form.meta.value.valid"
>
<Spinner v-if="editLoading" />
{{ $t('provider.saveChanges') }}
</Button>
</div>
</section>
<section
v-if="testResult"
class="mt-4 rounded-lg border p-4 space-y-3 text-sm"
>
<div class="flex items-center gap-2">
<span
class="inline-block size-2 rounded-full"
:class="testResult.reachable ? 'bg-green-500' : 'bg-red-500'"
/>
<span class="font-medium">
{{ testResult.reachable ? $t('provider.reachable') : $t('provider.unreachable') }}
</span>
<span
v-if="testResult.latency_ms"
class="text-muted-foreground"
>
{{ testResult.latency_ms }}ms
</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="testError"
class="text-destructive text-xs"
>
{{ testError }}
</div>
</section>
</form>
</template>
@@ -90,17 +150,23 @@
import {
Input,
Button,
Badge,
FormControl,
FormField,
FormItem,
Spinner,
} from '@memoh/ui'
import ConfirmPopover from '@/components/confirm-popover/index.vue'
import { computed, watch } from 'vue'
import { computed, ref, watch } from 'vue'
import { toTypedSchema } from '@vee-validate/zod'
import z from 'zod'
import { useForm } from 'vee-validate'
import type { ProvidersGetResponse } from '@memoh/sdk'
import { postProvidersByIdTest } from '@memoh/sdk'
import type { ProvidersGetResponse, ProvidersTestResponse, ProvidersCheckStatus } from '@memoh/sdk'
import { useI18n } from 'vue-i18n'
import { CLIENT_TYPE_META } from '@/constants/client-types'
const { t } = useI18n()
const props = defineProps<{
provider: Partial<ProvidersGetResponse> | undefined
@@ -113,6 +179,54 @@ const emit = defineEmits<{
delete: []
}>()
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
testResult.value = null
testError.value = ''
try {
const { data } = await postProvidersByIdTest({
path: { id: props.provider.id },
throwOnError: true,
})
testResult.value = data ?? null
} catch (err: unknown) {
testError.value = err instanceof Error ? err.message : t('provider.testFailed')
} finally {
testLoading.value = false
}
}
const providerSchema = toTypedSchema(z.object({
name: z.string().min(1),
base_url: z.string().min(1),
+102
View File
@@ -4055,6 +4055,56 @@ const docTemplate = `{
}
}
},
"/providers/{id}/test": {
"post": {
"description": "Probe a provider's base URL to check reachability, supported client types, and embedding support",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"providers"
],
"summary": "Test provider connectivity",
"parameters": [
{
"type": "string",
"description": "Provider ID (UUID)",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/providers.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"
}
}
}
}
},
"/search-providers": {
"get": {
"description": "List configured search providers",
@@ -6602,6 +6652,38 @@ 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": {
@@ -6660,6 +6742,26 @@ const docTemplate = `{
}
}
},
"providers.TestResponse": {
"type": "object",
"properties": {
"checks": {
"type": "object",
"additionalProperties": {
"$ref": "#/definitions/providers.CheckResult"
}
},
"latency_ms": {
"type": "integer"
},
"message": {
"type": "string"
},
"reachable": {
"type": "boolean"
}
}
},
"providers.UpdateRequest": {
"type": "object",
"properties": {
+102
View File
@@ -4046,6 +4046,56 @@
}
}
},
"/providers/{id}/test": {
"post": {
"description": "Probe a provider's base URL to check reachability, supported client types, and embedding support",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"providers"
],
"summary": "Test provider connectivity",
"parameters": [
{
"type": "string",
"description": "Provider ID (UUID)",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/providers.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"
}
}
}
}
},
"/search-providers": {
"get": {
"description": "List configured search providers",
@@ -6593,6 +6643,38 @@
}
}
},
"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": {
@@ -6651,6 +6733,26 @@
}
}
},
"providers.TestResponse": {
"type": "object",
"properties": {
"checks": {
"type": "object",
"additionalProperties": {
"$ref": "#/definitions/providers.CheckResult"
}
},
"latency_ms": {
"type": "integer"
},
"message": {
"type": "string"
},
"reachable": {
"type": "boolean"
}
}
},
"providers.UpdateRequest": {
"type": "object",
"properties": {
+70
View File
@@ -1220,6 +1220,29 @@ 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:
@@ -1259,6 +1282,19 @@ definitions:
updated_at:
type: string
type: object
providers.TestResponse:
properties:
checks:
additionalProperties:
$ref: '#/definitions/providers.CheckResult'
type: object
latency_ms:
type: integer
message:
type: string
reachable:
type: boolean
type: object
providers.UpdateRequest:
properties:
api_key:
@@ -4197,6 +4233,40 @@ paths:
summary: List provider models
tags:
- providers
/providers/{id}/test:
post:
consumes:
- application/json
description: Probe a provider's base URL to check reachability, supported client
types, and embedding support
parameters:
- description: Provider ID (UUID)
in: path
name: id
required: true
type: string
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/providers.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 provider connectivity
tags:
- providers
/providers/count:
get:
consumes: