feat(admin): clarify AI service configuration

This commit is contained in:
rxwatcher
2026-07-28 21:49:41 -04:00
committed by Quick
parent 7ab393fc3a
commit f0bc170113
8 changed files with 1142 additions and 418 deletions
+3
View File
@@ -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). "+
+180 -1
View File
@@ -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}
+6
View File
@@ -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