feat(admin): clarify AI service configuration
This commit is contained in:
@@ -2586,7 +2586,10 @@ func (h *AdminHandler) HandleUpdateSetting(w http.ResponseWriter, r *http.Reques
|
||||
return
|
||||
}
|
||||
req.Value = normalized
|
||||
case "ai.base_url", "ai.chat_model", "ai.asr_model":
|
||||
req.Value = strings.TrimSpace(req.Value)
|
||||
case "ai.asr_base_url":
|
||||
req.Value = strings.TrimSpace(req.Value)
|
||||
if llm.IsChatOnlyGateway(req.Value) {
|
||||
writeError(w, http.StatusBadRequest, "bad_request",
|
||||
"This endpoint cannot produce timestamped transcriptions (chat-only gateway). "+
|
||||
|
||||
@@ -3,16 +3,19 @@ package handlers
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/redis/go-redis/v9"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/ai/llm"
|
||||
"github.com/Silo-Server/silo-server/internal/cache"
|
||||
"github.com/Silo-Server/silo-server/internal/catalog"
|
||||
"github.com/Silo-Server/silo-server/internal/config"
|
||||
@@ -51,6 +54,11 @@ type mdblistSettingsCheckClient interface {
|
||||
Check(ctx context.Context) error
|
||||
}
|
||||
|
||||
type aiSettingsCheckClient interface {
|
||||
Chat(ctx context.Context, messages []llm.Message, jsonObject bool) (string, error)
|
||||
Transcribe(ctx context.Context, req llm.TranscribeRequest) (*llm.Transcription, error)
|
||||
}
|
||||
|
||||
type redisSettingsCheckAdapter struct {
|
||||
client *redis.Client
|
||||
}
|
||||
@@ -88,6 +96,10 @@ var newAdminMDBListSettingsCheckClient = func(apiKey string) mdblistSettingsChec
|
||||
return mdblist.NewClient(apiKey, nil)
|
||||
}
|
||||
|
||||
var newAdminAISettingsCheckClient = func(cfg llm.Config) aiSettingsCheckClient {
|
||||
return llm.NewClient(cfg)
|
||||
}
|
||||
|
||||
func (h *AdminHandler) HandleCheckSettingsConnection(w http.ResponseWriter, r *http.Request) {
|
||||
if h.SettingsRepo == nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Settings store not configured")
|
||||
@@ -109,7 +121,7 @@ func (h *AdminHandler) HandleCheckSettingsConnection(w http.ResponseWriter, r *h
|
||||
req.Values = map[string]string{}
|
||||
}
|
||||
|
||||
effectiveSettings, err := h.effectiveSettingsForConnectionCheck(r.Context(), req)
|
||||
effectiveSettings, err := h.effectiveSettingsForConnectionCheck(r.Context(), kind, req)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load settings")
|
||||
return
|
||||
@@ -131,6 +143,10 @@ func (h *AdminHandler) HandleCheckSettingsConnection(w http.ResponseWriter, r *h
|
||||
response = checkRedisConnection(r.Context(), cfg)
|
||||
case "recommendations_embedding":
|
||||
response = checkRecommendationsEmbeddingConnection(r.Context(), cfg)
|
||||
case "ai_chat":
|
||||
response = checkAIChatConnection(r.Context(), cfg)
|
||||
case "ai_transcription":
|
||||
response = checkAITranscriptionConnection(r.Context(), cfg)
|
||||
case "meilisearch":
|
||||
response = checkMeilisearchConnection(r.Context(), effectiveSettings)
|
||||
case "mdblist":
|
||||
@@ -155,6 +171,105 @@ func checkMDBListConnection(ctx context.Context, cfg *config.Config) connectionC
|
||||
return connectionCheckResponse{Success: true, Message: "MDBList API key verified."}
|
||||
}
|
||||
|
||||
func aiClientConfig(cfg *config.Config) llm.Config {
|
||||
return llm.Config{
|
||||
BaseURL: strings.TrimSpace(cfg.AI.BaseURL),
|
||||
APIKey: cfg.AI.APIKey,
|
||||
ChatModel: strings.TrimSpace(cfg.AI.ChatModel),
|
||||
ASRBaseURL: strings.TrimSpace(cfg.AI.ASRBaseURL),
|
||||
ASRAPIKey: cfg.AI.ASRAPIKey,
|
||||
ASRModel: strings.TrimSpace(cfg.AI.ASRModel),
|
||||
}
|
||||
}
|
||||
|
||||
func checkAIChatConnection(ctx context.Context, cfg *config.Config) connectionCheckResponse {
|
||||
if strings.TrimSpace(cfg.AI.BaseURL) == "" {
|
||||
return connectionCheckResponse{Success: false, Message: "Text AI base URL is required."}
|
||||
}
|
||||
if strings.TrimSpace(cfg.AI.ChatModel) == "" {
|
||||
return connectionCheckResponse{Success: false, Message: "Chat model is required."}
|
||||
}
|
||||
|
||||
client := newAdminAISettingsCheckClient(aiClientConfig(cfg))
|
||||
checkCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
defer cancel()
|
||||
if _, err := client.Chat(checkCtx, []llm.Message{
|
||||
{Role: "system", Content: "Return a JSON object with status set to ok."},
|
||||
{Role: "user", Content: "Check this Silo text translation connection."},
|
||||
}, true); err != nil {
|
||||
return connectionCheckResponse{
|
||||
Success: false,
|
||||
Message: fmt.Sprintf("Text AI connection check failed: %v", err),
|
||||
}
|
||||
}
|
||||
|
||||
return connectionCheckResponse{Success: true, Message: "Text AI connection successful."}
|
||||
}
|
||||
|
||||
func checkAITranscriptionConnection(ctx context.Context, cfg *config.Config) connectionCheckResponse {
|
||||
effectiveBaseURL := strings.TrimSpace(cfg.AI.ASRBaseURL)
|
||||
if effectiveBaseURL == "" {
|
||||
effectiveBaseURL = strings.TrimSpace(cfg.AI.BaseURL)
|
||||
}
|
||||
if effectiveBaseURL == "" {
|
||||
return connectionCheckResponse{
|
||||
Success: false,
|
||||
Message: "Speech-to-text base URL is required.",
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(cfg.AI.ASRModel) == "" {
|
||||
return connectionCheckResponse{
|
||||
Success: false,
|
||||
Message: "Transcription model is required.",
|
||||
}
|
||||
}
|
||||
|
||||
client := newAdminAISettingsCheckClient(aiClientConfig(cfg))
|
||||
checkCtx, cancel := context.WithTimeout(ctx, 35*time.Second)
|
||||
defer cancel()
|
||||
if _, err := client.Transcribe(checkCtx, llm.TranscribeRequest{
|
||||
Filename: "silo-connection-check.wav",
|
||||
Audio: silenceWAV(),
|
||||
Timeout: 30 * time.Second,
|
||||
}); err != nil {
|
||||
return connectionCheckResponse{
|
||||
Success: false,
|
||||
Message: fmt.Sprintf("Speech-to-text connection check failed: %v", err),
|
||||
}
|
||||
}
|
||||
|
||||
return connectionCheckResponse{
|
||||
Success: true,
|
||||
Message: "Speech-to-text connection successful.",
|
||||
}
|
||||
}
|
||||
|
||||
// silenceWAV returns 250 ms of 16 kHz mono PCM. It is long enough for
|
||||
// transcription providers to parse while keeping connection checks cheap.
|
||||
func silenceWAV() []byte {
|
||||
const (
|
||||
sampleRate = 16_000
|
||||
bitsPerSample = 16
|
||||
sampleCount = sampleRate / 4
|
||||
dataSize = sampleCount * bitsPerSample / 8
|
||||
)
|
||||
wav := make([]byte, 44+dataSize)
|
||||
copy(wav[0:4], "RIFF")
|
||||
binary.LittleEndian.PutUint32(wav[4:8], uint32(36+dataSize))
|
||||
copy(wav[8:12], "WAVE")
|
||||
copy(wav[12:16], "fmt ")
|
||||
binary.LittleEndian.PutUint32(wav[16:20], 16)
|
||||
binary.LittleEndian.PutUint16(wav[20:22], 1)
|
||||
binary.LittleEndian.PutUint16(wav[22:24], 1)
|
||||
binary.LittleEndian.PutUint32(wav[24:28], sampleRate)
|
||||
binary.LittleEndian.PutUint32(wav[28:32], sampleRate*bitsPerSample/8)
|
||||
binary.LittleEndian.PutUint16(wav[32:34], bitsPerSample/8)
|
||||
binary.LittleEndian.PutUint16(wav[34:36], bitsPerSample)
|
||||
copy(wav[36:40], "data")
|
||||
binary.LittleEndian.PutUint32(wav[40:44], dataSize)
|
||||
return wav
|
||||
}
|
||||
|
||||
func checkMeilisearchConnection(ctx context.Context, settings map[string]string) connectionCheckResponse {
|
||||
searchSettings, err := catalog.CatalogSearchSettingsFromMap(settings)
|
||||
if err != nil {
|
||||
@@ -173,6 +288,7 @@ func checkMeilisearchConnection(ctx context.Context, settings map[string]string)
|
||||
|
||||
func (h *AdminHandler) effectiveSettingsForConnectionCheck(
|
||||
ctx context.Context,
|
||||
kind string,
|
||||
req adminSettingsConnectionCheckRequest,
|
||||
) (map[string]string, error) {
|
||||
settings, err := h.SettingsRepo.GetAll(ctx)
|
||||
@@ -190,13 +306,76 @@ func (h *AdminHandler) effectiveSettingsForConnectionCheck(
|
||||
}
|
||||
merged[key] = value
|
||||
}
|
||||
|
||||
var storedAIConfig *config.Config
|
||||
if kind == "ai_chat" || kind == "ai_transcription" {
|
||||
storedAIConfig, err = config.LoadFromDB(merged)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
for _, key := range req.DirtyKeys {
|
||||
merged[key] = req.Values[key]
|
||||
}
|
||||
if storedAIConfig != nil {
|
||||
draftAIConfig, loadErr := config.LoadFromDB(merged)
|
||||
if loadErr != nil {
|
||||
return nil, loadErr
|
||||
}
|
||||
protectAIConnectionCheckSecrets(kind, req, storedAIConfig, draftAIConfig, merged)
|
||||
}
|
||||
|
||||
return merged, nil
|
||||
}
|
||||
|
||||
func protectAIConnectionCheckSecrets(
|
||||
kind string,
|
||||
req adminSettingsConnectionCheckRequest,
|
||||
storedCfg *config.Config,
|
||||
draftCfg *config.Config,
|
||||
settings map[string]string,
|
||||
) {
|
||||
storedEndpoint := storedCfg.AI.BaseURL
|
||||
draftEndpoint := draftCfg.AI.BaseURL
|
||||
if kind == "ai_transcription" {
|
||||
if strings.TrimSpace(storedCfg.AI.ASRBaseURL) != "" {
|
||||
storedEndpoint = storedCfg.AI.ASRBaseURL
|
||||
}
|
||||
if strings.TrimSpace(draftCfg.AI.ASRBaseURL) != "" {
|
||||
draftEndpoint = draftCfg.AI.ASRBaseURL
|
||||
}
|
||||
}
|
||||
if endpointAuthority(storedEndpoint) == endpointAuthority(draftEndpoint) {
|
||||
return
|
||||
}
|
||||
|
||||
if kind == "ai_transcription" && !hasExplicitDraftSecret(req, "ai.asr_api_key") {
|
||||
settings["ai.asr_api_key"] = ""
|
||||
}
|
||||
if !hasExplicitDraftSecret(req, "ai.api_key") {
|
||||
settings["ai.api_key"] = ""
|
||||
settings["subtitle_ai.api_key"] = ""
|
||||
}
|
||||
}
|
||||
|
||||
func hasExplicitDraftSecret(req adminSettingsConnectionCheckRequest, key string) bool {
|
||||
for _, dirtyKey := range req.DirtyKeys {
|
||||
if dirtyKey == key {
|
||||
return strings.TrimSpace(req.Values[key]) != ""
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func endpointAuthority(rawURL string) string {
|
||||
trimmed := strings.TrimSpace(rawURL)
|
||||
parsed, err := url.Parse(trimmed)
|
||||
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
|
||||
return strings.ToLower(trimmed)
|
||||
}
|
||||
return strings.ToLower(parsed.Scheme + "://" + parsed.Host)
|
||||
}
|
||||
|
||||
func checkS3PublicConnection(ctx context.Context, cfg *config.Config) connectionCheckResponse {
|
||||
if strings.TrimSpace(cfg.S3.Public.Endpoint) == "" {
|
||||
return connectionCheckResponse{Success: false, Message: "S3 endpoint is required."}
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/ai/llm"
|
||||
"github.com/Silo-Server/silo-server/internal/catalog"
|
||||
"github.com/Silo-Server/silo-server/internal/config"
|
||||
"github.com/Silo-Server/silo-server/internal/diagnostics"
|
||||
@@ -886,6 +887,239 @@ func (f *fakeEmbeddingsSettingsCheckClient) Embed(
|
||||
return [][]float32{{0.1, 0.2}}, nil
|
||||
}
|
||||
|
||||
type fakeAISettingsCheckClient struct {
|
||||
chat func(ctx context.Context, messages []llm.Message, jsonObject bool) (string, error)
|
||||
transcribe func(ctx context.Context, req llm.TranscribeRequest) (*llm.Transcription, error)
|
||||
}
|
||||
|
||||
func (f *fakeAISettingsCheckClient) Chat(
|
||||
ctx context.Context,
|
||||
messages []llm.Message,
|
||||
jsonObject bool,
|
||||
) (string, error) {
|
||||
if f.chat != nil {
|
||||
return f.chat(ctx, messages, jsonObject)
|
||||
}
|
||||
return `{"status":"ok"}`, nil
|
||||
}
|
||||
|
||||
func (f *fakeAISettingsCheckClient) Transcribe(
|
||||
ctx context.Context,
|
||||
req llm.TranscribeRequest,
|
||||
) (*llm.Transcription, error) {
|
||||
if f.transcribe != nil {
|
||||
return f.transcribe(ctx, req)
|
||||
}
|
||||
return &llm.Transcription{}, nil
|
||||
}
|
||||
|
||||
func TestHandleCheckSettingsConnectionAIChatDoesNotSendStoredKeyToDraftEndpoint(t *testing.T) {
|
||||
originalFactory := newAdminAISettingsCheckClient
|
||||
t.Cleanup(func() {
|
||||
newAdminAISettingsCheckClient = originalFactory
|
||||
})
|
||||
|
||||
var captured llm.Config
|
||||
var chatCalled bool
|
||||
newAdminAISettingsCheckClient = func(cfg llm.Config) aiSettingsCheckClient {
|
||||
captured = cfg
|
||||
return &fakeAISettingsCheckClient{
|
||||
chat: func(_ context.Context, _ []llm.Message, jsonObject bool) (string, error) {
|
||||
chatCalled = true
|
||||
if !jsonObject {
|
||||
t.Fatal("chat connection check did not request a JSON response")
|
||||
}
|
||||
return `{"status":"ok"}`, nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
handler := &AdminHandler{
|
||||
SettingsRepo: &fakeServerSettingsStore{
|
||||
values: map[string]string{
|
||||
"ai.base_url": "https://persisted.example.test",
|
||||
"ai.chat_model": "persisted-model",
|
||||
"ai.api_key": "persisted-secret",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
rec := performSettingsCheckRequest(
|
||||
t,
|
||||
handler,
|
||||
"/admin/settings/check/ai_chat",
|
||||
map[string]any{
|
||||
"values": map[string]string{
|
||||
"ai.base_url": "https://draft.example.test",
|
||||
"ai.chat_model": "draft-model",
|
||||
"ai.api_key": "",
|
||||
},
|
||||
"dirty_keys": []string{"ai.base_url", "ai.chat_model"},
|
||||
},
|
||||
)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
var response connectionCheckResponse
|
||||
if err := json.NewDecoder(rec.Body).Decode(&response); err != nil {
|
||||
t.Fatalf("Decode() returned error: %v", err)
|
||||
}
|
||||
if !response.Success || !chatCalled {
|
||||
t.Fatalf("response = %+v, chatCalled = %v", response, chatCalled)
|
||||
}
|
||||
if captured.BaseURL != "https://draft.example.test" || captured.ChatModel != "draft-model" {
|
||||
t.Fatalf("captured config = %+v, want draft endpoint and model", captured)
|
||||
}
|
||||
if captured.APIKey != "" {
|
||||
t.Fatalf("captured API key = %q, want no stored secret for a changed endpoint", captured.APIKey)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleCheckSettingsConnectionAIChatReusesStoredKeyForSameEndpoint(t *testing.T) {
|
||||
originalFactory := newAdminAISettingsCheckClient
|
||||
t.Cleanup(func() {
|
||||
newAdminAISettingsCheckClient = originalFactory
|
||||
})
|
||||
|
||||
var captured llm.Config
|
||||
newAdminAISettingsCheckClient = func(cfg llm.Config) aiSettingsCheckClient {
|
||||
captured = cfg
|
||||
return &fakeAISettingsCheckClient{}
|
||||
}
|
||||
|
||||
handler := &AdminHandler{
|
||||
SettingsRepo: &fakeServerSettingsStore{
|
||||
values: map[string]string{
|
||||
"ai.base_url": "https://persisted.example.test/v1",
|
||||
"ai.chat_model": "persisted-model",
|
||||
"ai.api_key": "persisted-secret",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
rec := performSettingsCheckRequest(
|
||||
t,
|
||||
handler,
|
||||
"/admin/settings/check/ai_chat",
|
||||
map[string]any{
|
||||
"values": map[string]string{
|
||||
"ai.base_url": "https://persisted.example.test/other-path",
|
||||
"ai.chat_model": "draft-model",
|
||||
},
|
||||
"dirty_keys": []string{"ai.base_url", "ai.chat_model"},
|
||||
},
|
||||
)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if captured.APIKey != "persisted-secret" {
|
||||
t.Fatalf("captured API key = %q, want stored secret for unchanged authority", captured.APIKey)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleCheckSettingsConnectionAITranscriptionUsesDedicatedASR(t *testing.T) {
|
||||
originalFactory := newAdminAISettingsCheckClient
|
||||
t.Cleanup(func() {
|
||||
newAdminAISettingsCheckClient = originalFactory
|
||||
})
|
||||
|
||||
var captured llm.Config
|
||||
var request llm.TranscribeRequest
|
||||
newAdminAISettingsCheckClient = func(cfg llm.Config) aiSettingsCheckClient {
|
||||
captured = cfg
|
||||
return &fakeAISettingsCheckClient{
|
||||
transcribe: func(_ context.Context, req llm.TranscribeRequest) (*llm.Transcription, error) {
|
||||
request = req
|
||||
return &llm.Transcription{}, nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
handler := &AdminHandler{
|
||||
SettingsRepo: &fakeServerSettingsStore{
|
||||
values: map[string]string{
|
||||
"ai.base_url": "https://chat.example.test",
|
||||
"ai.api_key": "chat-secret",
|
||||
"ai.chat_model": "chat-model",
|
||||
"ai.asr_base_url": "https://whisper.example.test",
|
||||
"ai.asr_api_key": "asr-secret",
|
||||
"ai.asr_model": "whisper-model",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
rec := performSettingsCheckRequest(
|
||||
t,
|
||||
handler,
|
||||
"/admin/settings/check/ai_transcription",
|
||||
map[string]any{"values": map[string]string{}, "dirty_keys": []string{}},
|
||||
)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
var response connectionCheckResponse
|
||||
if err := json.NewDecoder(rec.Body).Decode(&response); err != nil {
|
||||
t.Fatalf("Decode() returned error: %v", err)
|
||||
}
|
||||
if !response.Success {
|
||||
t.Fatalf("response.Success = false, message = %q", response.Message)
|
||||
}
|
||||
if captured.ASRBaseURL != "https://whisper.example.test" ||
|
||||
captured.ASRAPIKey != "asr-secret" ||
|
||||
captured.ASRModel != "whisper-model" {
|
||||
t.Fatalf("captured ASR config = %+v", captured)
|
||||
}
|
||||
if len(request.Audio) == 0 || request.Filename == "" || request.Timeout <= 0 {
|
||||
t.Fatalf("transcription probe request = %+v, want bounded WAV probe", request)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleCheckSettingsConnectionAITranscriptionDoesNotSendStoredKeysToDraftEndpoint(t *testing.T) {
|
||||
originalFactory := newAdminAISettingsCheckClient
|
||||
t.Cleanup(func() {
|
||||
newAdminAISettingsCheckClient = originalFactory
|
||||
})
|
||||
|
||||
var captured llm.Config
|
||||
newAdminAISettingsCheckClient = func(cfg llm.Config) aiSettingsCheckClient {
|
||||
captured = cfg
|
||||
return &fakeAISettingsCheckClient{}
|
||||
}
|
||||
|
||||
handler := &AdminHandler{
|
||||
SettingsRepo: &fakeServerSettingsStore{
|
||||
values: map[string]string{
|
||||
"ai.base_url": "https://chat.example.test",
|
||||
"ai.api_key": "chat-secret",
|
||||
"ai.chat_model": "chat-model",
|
||||
"ai.asr_base_url": "https://stored-whisper.example.test",
|
||||
"ai.asr_api_key": "asr-secret",
|
||||
"ai.asr_model": "whisper-model",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
rec := performSettingsCheckRequest(
|
||||
t,
|
||||
handler,
|
||||
"/admin/settings/check/ai_transcription",
|
||||
map[string]any{
|
||||
"values": map[string]string{
|
||||
"ai.asr_base_url": "https://draft-whisper.example.test",
|
||||
},
|
||||
"dirty_keys": []string{"ai.asr_base_url"},
|
||||
},
|
||||
)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if captured.ASRAPIKey != "" || captured.APIKey != "" {
|
||||
t.Fatalf("captured config = %+v, want no stored ASR or inherited chat secret", captured)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleCheckSettingsConnectionS3UsesPersistedSensitiveValues(t *testing.T) {
|
||||
originalFactory := newAdminS3SettingsCheckClient
|
||||
t.Cleanup(func() {
|
||||
@@ -1238,6 +1472,44 @@ func TestAdminUpdateSettingReportsRestartRequired(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminUpdateSettingNormalizesAIEndpointAndModelValues(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
key string
|
||||
value string
|
||||
want string
|
||||
}{
|
||||
{key: "ai.base_url", value: " https://text.example.test/v1 ", want: "https://text.example.test/v1"},
|
||||
{key: "ai.chat_model", value: " chat-model ", want: "chat-model"},
|
||||
{key: "ai.asr_base_url", value: " https://speech.example.test ", want: "https://speech.example.test"},
|
||||
{key: "ai.asr_model", value: " whisper-model ", want: "whisper-model"},
|
||||
} {
|
||||
t.Run(tc.key, func(t *testing.T) {
|
||||
settings := &fakeServerSettingsStore{}
|
||||
handler := &AdminHandler{SettingsRepo: settings}
|
||||
body, err := json.Marshal(updateSettingRequest{Value: tc.value})
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal() returned error: %v", err)
|
||||
}
|
||||
req := httptest.NewRequest(
|
||||
http.MethodPut,
|
||||
"/admin/settings/"+tc.key,
|
||||
bytes.NewReader(body),
|
||||
)
|
||||
req = withChiParam(req, "key", tc.key)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
handler.HandleUpdateSetting(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if settings.values[tc.key] != tc.want {
|
||||
t.Fatalf("stored value = %q, want %q", settings.values[tc.key], tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminUpdatePolicyEditorEnabledValidation(t *testing.T) {
|
||||
settings := &fakeServerSettingsStore{}
|
||||
handler := &AdminHandler{SettingsRepo: settings}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { ConnectionCheckAction } from "./ConnectionCheckAction";
|
||||
|
||||
describe("ConnectionCheckAction", () => {
|
||||
it("announces connection results", () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<ConnectionCheckAction
|
||||
onClick={vi.fn()}
|
||||
result={{ success: true, message: "Connection successful." }}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(markup).toContain('role="status"');
|
||||
expect(markup).toContain('aria-live="polite"');
|
||||
});
|
||||
});
|
||||
@@ -30,7 +30,11 @@ export function ConnectionCheckAction({
|
||||
{isPending ? pendingLabel : label}
|
||||
</Button>
|
||||
{result ? (
|
||||
<span className={`text-sm ${result.success ? "text-green-600" : "text-red-600"}`}>
|
||||
<span
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
className={`text-sm ${result.success ? "text-green-600" : "text-red-600"}`}
|
||||
>
|
||||
{result.message}
|
||||
</span>
|
||||
) : null}
|
||||
|
||||
@@ -218,12 +218,17 @@ export const ADMIN_SETTINGS_GROUPS: AdminSettingsSearchGroup[] = [
|
||||
"quota",
|
||||
],
|
||||
settings: settingIndex(
|
||||
"Text translation",
|
||||
"Base URL",
|
||||
"Chat model",
|
||||
"API Key",
|
||||
"Test Text AI",
|
||||
"Speech-to-text",
|
||||
"Transcription model",
|
||||
"Transcription base URL",
|
||||
"Transcription API key",
|
||||
"Test Speech-to-Text",
|
||||
"Features",
|
||||
"Max concurrent jobs",
|
||||
"Subtitle translation",
|
||||
"Subtitle generation from audio",
|
||||
@@ -234,6 +239,7 @@ export const ADMIN_SETTINGS_GROUPS: AdminSettingsSearchGroup[] = [
|
||||
"Transcription chunk length (seconds)",
|
||||
"Transcription limit per account",
|
||||
"Transcription limit period",
|
||||
"Advanced",
|
||||
),
|
||||
icon: Sparkles,
|
||||
},
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import AIServicesSettings from "./AIServicesSettings";
|
||||
|
||||
const values: Record<string, string> = {
|
||||
"ai.base_url": "https://text.example.test",
|
||||
"ai.chat_model": "chat-model",
|
||||
"ai.asr_base_url": "",
|
||||
"ai.asr_model": "whisper-model",
|
||||
"ai.max_concurrent_jobs": "2",
|
||||
"subtitle_ai.base_url": "https://legacy.example.test",
|
||||
"subtitle_ai.chat_model": "legacy-chat-model",
|
||||
"subtitle_ai.max_concurrent_jobs": "3",
|
||||
"subtitle_ai.enabled": "true",
|
||||
"subtitle_ai.transcribe_enabled": "false",
|
||||
"subtitle_ai.batch_size": "40",
|
||||
"subtitle_ai.context_neighbors": "2",
|
||||
"subtitle_ai.asr_chunk_seconds": "600",
|
||||
"subtitle_ai.transcribe_quota_jobs": "0",
|
||||
"subtitle_ai.transcribe_quota_period": "day",
|
||||
"metadata_ai.enabled": "false",
|
||||
"metadata_ai.on_view": "button",
|
||||
};
|
||||
|
||||
const useSettingsFormMock = vi.fn((_options?: { keys: string[] }) => ({
|
||||
isLoading: false,
|
||||
getValue: (key: string) => values[key] ?? "",
|
||||
setValue: vi.fn(),
|
||||
dirtyCount: 0,
|
||||
dirtyKeys: [],
|
||||
isDirty: vi.fn(() => false),
|
||||
save: vi.fn(),
|
||||
discard: vi.fn(),
|
||||
isSaving: false,
|
||||
restartRequired: false,
|
||||
sensitiveConfigured: ["subtitle_ai.api_key"],
|
||||
sensitiveManagedByEnv: [],
|
||||
buildConnectionCheckRequest: vi.fn(() => ({ values: {}, dirty_keys: [] })),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/useSettingsForm", () => ({
|
||||
useSettingsForm: (options: { keys: string[] }) => useSettingsFormMock(options),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/queries/admin/settings", () => ({
|
||||
useAdminServerSettings: () => ({ data: values }),
|
||||
useAdminSensitiveStatus: () => ({ data: { configured: ["ai.api_key"] } }),
|
||||
useUpdateServerSetting: () => ({ mutateAsync: vi.fn(), isPending: false }),
|
||||
useCheckAdminSettingsConnection: () => ({ mutateAsync: vi.fn(), isPending: false }),
|
||||
}));
|
||||
|
||||
describe("AIServicesSettings", () => {
|
||||
it("separates text translation from speech-to-text configuration", () => {
|
||||
const markup = renderToStaticMarkup(<AIServicesSettings />);
|
||||
|
||||
expect(markup).toContain("Text translation");
|
||||
expect(markup).toContain("Speech-to-text");
|
||||
expect(markup).toContain("Test Text AI");
|
||||
expect(markup).toContain("Test Speech-to-Text");
|
||||
expect(markup).toContain("Uses the Text translation endpoint");
|
||||
});
|
||||
|
||||
it("shows effective legacy endpoint values until modern keys are saved", () => {
|
||||
const currentBaseURL = values["ai.base_url"]!;
|
||||
const currentChatModel = values["ai.chat_model"]!;
|
||||
values["ai.base_url"] = "";
|
||||
values["ai.chat_model"] = "";
|
||||
|
||||
try {
|
||||
const markup = renderToStaticMarkup(<AIServicesSettings />);
|
||||
|
||||
expect(markup).toContain("https://legacy.example.test");
|
||||
expect(markup).toContain("legacy-chat-model");
|
||||
} finally {
|
||||
values["ai.base_url"] = currentBaseURL;
|
||||
values["ai.chat_model"] = currentChatModel;
|
||||
}
|
||||
});
|
||||
|
||||
it("marks known chat-only fallback endpoints as incompatible with speech-to-text", () => {
|
||||
const currentBaseURL = values["ai.base_url"]!;
|
||||
values["ai.base_url"] = "https://openrouter.ai/api";
|
||||
|
||||
try {
|
||||
const markup = renderToStaticMarkup(<AIServicesSettings />);
|
||||
|
||||
expect(markup).toContain("Incompatible endpoint");
|
||||
} finally {
|
||||
values["ai.base_url"] = currentBaseURL;
|
||||
}
|
||||
});
|
||||
|
||||
it("exposes transcription preset selection to assistive technology", () => {
|
||||
const markup = renderToStaticMarkup(<AIServicesSettings />);
|
||||
|
||||
expect(markup).toContain('aria-pressed="false"');
|
||||
});
|
||||
|
||||
it("explains feature dependencies and keeps advanced tuning secondary", () => {
|
||||
const markup = renderToStaticMarkup(<AIServicesSettings />);
|
||||
|
||||
expect(markup).toContain("Text AI required");
|
||||
expect(markup).toContain("Speech-to-text required");
|
||||
expect(markup).toContain("Inactive until Description translation is enabled");
|
||||
expect(markup).toContain("Advanced");
|
||||
});
|
||||
|
||||
it("points recommendation embeddings to their separate configuration", () => {
|
||||
const markup = renderToStaticMarkup(<AIServicesSettings />);
|
||||
|
||||
expect(markup).toContain("Recommendation embeddings are configured separately");
|
||||
expect(markup).toContain('href="/admin/recommendations"');
|
||||
expect(markup).not.toContain("Changes take effect after a server restart");
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user