diff --git a/apps/api/src/routes/models.ts b/apps/api/src/routes/models.ts index 3efe6c47..7b3f3a25 100644 --- a/apps/api/src/routes/models.ts +++ b/apps/api/src/routes/models.ts @@ -10,6 +10,7 @@ import { toPublicModelRecord, type ModelCredentials, } from "@kiwi/ai/models"; +import { probeModelConfiguration } from "@kiwi/ai/probe"; import { db } from "@kiwi/db"; import { AI_MODEL_ADAPTER_VALUES, @@ -61,6 +62,20 @@ const patchCredentialsSchema = z.object({ resourceName: z.string().trim().optional(), }); +// The test probe mirrors the create shape, except the API key may be omitted +// when model_id points at a stored model whose key should be reused. +const testModelSchema = z.object({ + model_id: z.string().trim().min(1).optional(), + type: modelTypeSchema, + adapter: modelAdapterSchema, + provider_model: z.string().trim().min(1), + credentials: z.object({ + apiKey: z.string().trim().min(1).optional(), + url: z.string().trim().min(1).optional(), + resourceName: z.string().trim().min(1).optional(), + }), +}); + const patchModelSchema = z.object({ display_name: z.string().trim().min(1).optional(), adapter: modelAdapterSchema.optional(), @@ -74,7 +89,11 @@ function mapModelError(status: RouteStatus, error: unknown) { return status(500, errorResponse("Internal server error", API_ERROR_CODES.INTERNAL_SERVER_ERROR)); } - switch (error.message) { + // Result.tryPromise wraps thrown errors in an UnhandledException whose + // cause carries the original error code (same pattern as mapChatError). + const errorCode = error.cause instanceof Error ? error.cause.message : error.message; + + switch (errorCode) { case API_ERROR_CODES.UNAUTHORIZED: return status(401, errorResponse("Unauthorized", API_ERROR_CODES.UNAUTHORIZED)); case API_ERROR_CODES.FORBIDDEN: @@ -266,6 +285,55 @@ export const modelsRoute = new Elysia({ prefix: "/models" }) body: createModelSchema, } ) + .post( + "/test", + async ({ body, status, user }) => + runModelAction({ + user, + status, + action: async (currentUser) => { + const membership = await requireOrganizationAdmin(currentUser); + let apiKey = body.credentials.apiKey; + + // Reusing the stored key with caller-supplied connection + // config grants nothing a PATCH does not already allow + // (kept key + new url); org admins are trusted with the + // credentials of the models they administer. + if (!apiKey) { + if (!body.model_id) { + throw new Error(API_ERROR_CODES.INVALID_MODEL); + } + const storedModel = await getModelForUpdate(db, membership.organizationId, body.model_id); + apiKey = decryptModelCredentials(storedModel.encryptedCredentials, env.AUTH_SECRET).apiKey; + } + + const credentials = normalizeCredentials({ + apiKey, + url: body.credentials.url, + resourceName: body.credentials.resourceName, + }); + const providerModel = body.provider_model.trim(); + + assertCreateModelInput({ + type: body.type, + adapter: body.adapter, + providerModel, + credentials, + }); + + return probeModelConfiguration({ + type: body.type, + adapter: body.adapter, + providerModel, + credentials, + }); + }, + success: (value) => status(200, successResponse(value)), + }), + { + body: testModelSchema, + } + ) .patch( "/:modelId", async ({ body, params, status, user }) => diff --git a/apps/frontend/components/admin/ModelFormDialog.test.tsx b/apps/frontend/components/admin/ModelFormDialog.test.tsx index e1883ad9..e898153d 100644 --- a/apps/frontend/components/admin/ModelFormDialog.test.tsx +++ b/apps/frontend/components/admin/ModelFormDialog.test.tsx @@ -2,8 +2,9 @@ import { screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { beforeEach, describe, expect, test, vi } from "vitest"; -const { createModel, updateModel } = vi.hoisted(() => ({ +const { createModel, testModelConnection, updateModel } = vi.hoisted(() => ({ createModel: vi.fn(), + testModelConnection: vi.fn(), updateModel: vi.fn(), })); @@ -27,6 +28,7 @@ Object.defineProperties(HTMLElement.prototype, { vi.mock("@/lib/api/models", () => ({ createModel, + testModelConnection, updateModel, })); @@ -88,6 +90,7 @@ describe("ModelFormDialog", () => { vi.clearAllMocks(); createModel.mockResolvedValue(adminModel); updateModel.mockResolvedValue(adminModel); + testModelConnection.mockResolvedValue({ ok: true }); }); test("creates a model with a model id slugged from the display name", async () => { @@ -287,6 +290,63 @@ describe("ModelFormDialog", () => { }); }); + test("blocks the save and shows the reason when the connection probe fails", async () => { + testModelConnection.mockResolvedValue({ ok: false, reason: "auth", message: "invalid api key" }); + const user = userEvent.setup(); + const onSaved = vi.fn(); + renderWithProviders(); + + await user.type(screen.getByLabelText("Anzeigename"), "GPT 4.1 Mini"); + await user.type(screen.getByLabelText("Provider-Modell-Name"), "gpt-4.1-mini"); + await user.type(screen.getByLabelText("API-Schlüssel"), "wrong-key"); + await user.click(screen.getByRole("button", { name: "Modell hinzufügen" })); + + expect( + await screen.findByText("Authentifizierung fehlgeschlagen. Prüfe den API-Schlüssel.") + ).toBeInTheDocument(); + expect(screen.getByText("invalid api key")).toBeInTheDocument(); + expect(createModel).not.toHaveBeenCalled(); + expect(onSaved).not.toHaveBeenCalled(); + }); + + test("skips the connection probe when only the display name changes", async () => { + const user = userEvent.setup(); + const onSaved = vi.fn(); + renderWithProviders( + + ); + + const nameInput = screen.getByLabelText("Anzeigename"); + await user.clear(nameInput); + await user.type(nameInput, "GPT-4.1 mini (renamed)"); + await user.click(screen.getByRole("button", { name: "Änderungen speichern" })); + + await waitFor(() => expect(onSaved).toHaveBeenCalled()); + expect(testModelConnection).not.toHaveBeenCalled(); + }); + + test("probes with the stored key reference when the key stays empty", async () => { + const user = userEvent.setup(); + const onSaved = vi.fn(); + renderWithProviders( + + ); + + const urlInput = screen.getByLabelText("Endpunkt-URL"); + await user.clear(urlInput); + await user.type(urlInput, "https://other.example.com/v1"); + await user.click(screen.getByRole("button", { name: "Änderungen speichern" })); + + await waitFor(() => expect(onSaved).toHaveBeenCalled()); + expect(testModelConnection).toHaveBeenCalledWith(expect.anything(), { + type: "text", + adapter: "openaiAPI", + provider_model: "gpt-oss-120b", + credentials: { url: "https://other.example.com/v1" }, + model_id: "local-llm", + }); + }); + test("closes without a request when nothing changed", async () => { const user = userEvent.setup(); const onOpenChange = vi.fn(); diff --git a/apps/frontend/components/admin/ModelFormDialog.tsx b/apps/frontend/components/admin/ModelFormDialog.tsx index 46cc43b2..1ea0034c 100644 --- a/apps/frontend/components/admin/ModelFormDialog.tsx +++ b/apps/frontend/components/admin/ModelFormDialog.tsx @@ -14,7 +14,7 @@ import { Label } from "@/components/ui/label"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Switch } from "@/components/ui/switch"; import { ApiError } from "@/lib/api/client"; -import { createModel, updateModel } from "@/lib/api/models"; +import { createModel, testModelConnection, updateModel } from "@/lib/api/models"; import { useAppTranslations } from "@/lib/i18n/use-app-translations"; import { useApiClient } from "@/providers/ApiClientProvider"; import type { @@ -26,7 +26,7 @@ import type { ModelPatchInput, } from "@kiwi/contracts"; import { AI_MODEL_ADAPTER_VALUES } from "@kiwi/contracts"; -import { Loader2 } from "lucide-react"; +import { AlertCircle, Loader2 } from "lucide-react"; import type React from "react"; import { useEffect, useState } from "react"; import { toast } from "sonner"; @@ -79,6 +79,8 @@ export function slugifyModelId(value: string): string { .replace(/^-+|-+$/g, ""); } +type ConnectionError = { text: string; detail?: string }; + type ModelFormDialogProps = { open: boolean; onOpenChange: (open: boolean) => void; @@ -106,6 +108,7 @@ export function ModelFormDialog({ open, onOpenChange, type, model, onSaved }: Mo const [resourceName, setResourceName] = useState(""); const [isDefault, setIsDefault] = useState(false); const [loading, setLoading] = useState(false); + const [connectionError, setConnectionError] = useState(null); useEffect(() => { if (!open) { @@ -118,12 +121,19 @@ export function ModelFormDialog({ open, onOpenChange, type, model, onSaved }: Mo setProviderModel(model?.provider_model ?? ""); setContextWindow(String(model?.context_window ?? DEFAULT_CONTEXT_WINDOW_TOKENS)); setContextWindowError(null); + setConnectionError(null); setApiKey(""); setUrl(model?.url ?? ""); setResourceName(model?.resource_name ?? ""); setIsDefault(false); }, [open, model, type]); + // The error only describes the configuration it was tested against, so + // any change to the connection fields invalidates it. + useEffect(() => { + setConnectionError(null); + }, [adapter, providerModel, apiKey, url, resourceName]); + const adapterOptions = adapterOptionsForType(type); const modelId = modelIdTouched ? modelIdInput : slugifyModelId(displayName); const showContextWindow = supportsContextWindow(type); @@ -155,6 +165,42 @@ export function ModelFormDialog({ open, onOpenChange, type, model, onSaved }: Mo (!requireUrl || url.trim().length > 0) && (!requireResourceName || resourceName.trim().length > 0); + // On edit a save only needs a probe when connection config changed; a + // pure rename must not fail because the provider is briefly unreachable. + const connectionChanged = + !isEdit || + !model || + adapter !== model.adapter || + providerModel.trim() !== model.provider_model || + apiKey.trim().length > 0 || + url.trim() !== (model.url ?? "") || + resourceName.trim() !== (model.resource_name ?? ""); + + // Probes the connection with the current form values; on edit a blank API + // key means the stored key is used, referenced via model_id. + const verifyConnection = async (): Promise => { + const result = await testModelConnection(apiClient, { + type, + adapter, + provider_model: providerModel.trim(), + credentials: { + ...(apiKey.trim() ? { apiKey: apiKey.trim() } : {}), + ...(url.trim() ? { url: url.trim() } : {}), + ...(resourceName.trim() ? { resourceName: resourceName.trim() } : {}), + }, + ...(isEdit && model && !apiKey.trim() ? { model_id: model.model_id } : {}), + }); + + if (!result.ok) { + setConnectionError({ + text: t(`settings.models.test.error.${result.reason}`), + detail: result.message, + }); + } + + return result.ok; + }; + const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); if (showContextWindow && contextWindowValue === null) { @@ -167,8 +213,12 @@ export function ModelFormDialog({ open, onOpenChange, type, model, onSaved }: Mo return; } setContextWindowError(null); + setConnectionError(null); setLoading(true); try { + if (connectionChanged && !(await verifyConnection())) { + return; + } if (isEdit && model) { const patch: ModelPatchInput = {}; if (displayName.trim() !== model.display_name) { @@ -375,6 +425,17 @@ export function ModelFormDialog({ open, onOpenChange, type, model, onSaved }: Mo ) : null} + {connectionError ? ( + + + + {connectionError.text} + + {connectionError.detail ? ( + {connectionError.detail} + ) : null} + + ) : null} onOpenChange(false)} disabled={loading}> {t("cancel")} diff --git a/apps/frontend/lib/api/models.ts b/apps/frontend/lib/api/models.ts index 218ae0e2..a78a9670 100644 --- a/apps/frontend/lib/api/models.ts +++ b/apps/frontend/lib/api/models.ts @@ -10,6 +10,8 @@ import type { ApiResponse, ModelCreateInput, ModelPatchInput, + ModelTestInput, + ModelTestResult, PublicModelListItem, } from "@kiwi/contracts"; import { unwrapApiResponse, type KiwiApiClient } from "./client"; @@ -64,6 +66,15 @@ export async function updateModel( return unwrapApiResponse(response); } +/** + * Probes the given configuration against the provider without persisting it. + * Omit credentials.apiKey and pass model_id to test with the stored key. + */ +export async function testModelConnection(client: KiwiApiClient, input: ModelTestInput): Promise { + const response = await client.post>("/models/test", input); + return unwrapApiResponse(response); +} + /** Makes the AI Model the default for its type, clearing the previous default. */ export async function setDefaultModel(client: KiwiApiClient, modelId: string): Promise { const response = await client.post(`${modelPath(modelId)}/default`); diff --git a/apps/frontend/messages/de.json b/apps/frontend/messages/de.json index ba91536c..58ba84a4 100644 --- a/apps/frontend/messages/de.json +++ b/apps/frontend/messages/de.json @@ -443,6 +443,10 @@ "settings.models.adapter.azure": "Azure OpenAI", "settings.models.adapter.anthropic": "Anthropic", "settings.models.adapter.openaiAPI": "OpenAI-kompatible API", + "settings.models.test.error.auth": "Authentifizierung fehlgeschlagen. Prüfe den API-Schlüssel.", + "settings.models.test.error.not_found": "Das Provider-Modell wurde nicht gefunden. Prüfe Provider-Modell-Name und Endpunkt.", + "settings.models.test.error.unreachable": "Der Anbieter ist nicht erreichbar. Prüfe Endpunkt-URL und Netzwerkzugriff.", + "settings.models.test.error.unknown": "Der Verbindungstest ist fehlgeschlagen.", "settings.models.created.renamed": "Als „{id}“ angelegt, da die gewünschte ID bereits vergeben oder normalisiert wurde.", "settings.models.error.invalid": "Die Modellkonfiguration ist für den gewählten Adapter ungültig.", "settings.models.delete.title": "KI-Modell löschen", diff --git a/apps/frontend/messages/en.json b/apps/frontend/messages/en.json index 2a369897..f3b1e3cd 100644 --- a/apps/frontend/messages/en.json +++ b/apps/frontend/messages/en.json @@ -443,6 +443,10 @@ "settings.models.adapter.azure": "Azure OpenAI", "settings.models.adapter.anthropic": "Anthropic", "settings.models.adapter.openaiAPI": "OpenAI-compatible API", + "settings.models.test.error.auth": "Authentication failed. Check the API key.", + "settings.models.test.error.not_found": "The provider model was not found. Check the provider model name and endpoint.", + "settings.models.test.error.unreachable": "The provider could not be reached. Check the endpoint URL and network access.", + "settings.models.test.error.unknown": "The connection test failed.", "settings.models.created.renamed": "Created as \"{id}\" because the requested ID was taken or normalized.", "settings.models.error.invalid": "The model configuration is invalid for the selected adapter.", "settings.models.delete.title": "Delete AI model", diff --git a/packages/ai/package.json b/packages/ai/package.json index f288e7cf..28dc4c37 100644 --- a/packages/ai/package.json +++ b/packages/ai/package.json @@ -9,6 +9,7 @@ "./lock": "./src/lock.ts", "./mcp": "./src/mcp/index.ts", "./models": "./src/models.ts", + "./probe": "./src/probe.ts", "./tools/toolsets": "./src/tools/toolsets.ts", "./ui": "./src/ui.ts", "./prompts/*": "./src/prompts/*.ts" diff --git a/packages/ai/src/__tests__/probe.test.ts b/packages/ai/src/__tests__/probe.test.ts new file mode 100644 index 00000000..d06659f7 --- /dev/null +++ b/packages/ai/src/__tests__/probe.test.ts @@ -0,0 +1,148 @@ +import { describe, expect, mock, test } from "bun:test"; +import { APICallError } from "@ai-sdk/provider"; + +const generateTextMock = mock(async () => ({ text: "" })); +const embedMock = mock(async () => ({ embedding: [0] })); + +// Covers every runtime export the ../probe import chain pulls from "ai". +mock.module("ai", () => ({ + generateText: generateTextMock, + embed: embedMock, + stepCountIs: () => Symbol("stop"), + ToolLoopAgent: class { + generate = mock(async () => ({ text: "" })); + }, + tool: >(definition: T) => definition, +})); + +mock.module("@kiwi/db", () => ({ + db: {}, +})); + +const { buildProbeWav, classifyModelProbeError, probeModelConfiguration } = await import("../probe"); + +function apiCallError(options: { statusCode?: number; responseBody?: string; message?: string; cause?: unknown }) { + return new APICallError({ + message: options.message ?? "request failed", + url: "https://provider.example/v1/chat/completions", + requestBodyValues: {}, + statusCode: options.statusCode, + responseBody: options.responseBody, + cause: options.cause, + }); +} + +const TEXT_PROBE_INPUT = { + type: "text", + adapter: "openai", + providerModel: "test-model", + credentials: { apiKey: "test-key" }, +} as const; + +describe("classifyModelProbeError", () => { + test("classifies 401 and 403 as auth", () => { + expect(classifyModelProbeError(apiCallError({ statusCode: 401 })).reason).toBe("auth"); + expect(classifyModelProbeError(apiCallError({ statusCode: 403 })).reason).toBe("auth"); + }); + + test("classifies 404 as not_found", () => { + expect(classifyModelProbeError(apiCallError({ statusCode: 404 })).reason).toBe("not_found"); + }); + + test("classifies other status codes as unknown", () => { + expect(classifyModelProbeError(apiCallError({ statusCode: 429 })).reason).toBe("unknown"); + expect(classifyModelProbeError(apiCallError({ statusCode: 500 })).reason).toBe("unknown"); + }); + + test("classifies APICallError without status code as unreachable", () => { + const error = apiCallError({ message: "Cannot connect to API: fetch failed" }); + expect(classifyModelProbeError(error).reason).toBe("unreachable"); + }); + + test("classifies timeouts and network failures as unreachable", () => { + expect(classifyModelProbeError(new DOMException("timed out", "TimeoutError")).reason).toBe("unreachable"); + expect( + classifyModelProbeError(new TypeError("fetch failed", { cause: { code: "ECONNREFUSED" } })).reason + ).toBe("unreachable"); + expect(classifyModelProbeError(new Error("getaddrinfo failed", { cause: { code: "ENOTFOUND" } })).reason).toBe( + "unreachable" + ); + }); + + test("extracts status codes from transcription request errors", () => { + const error = new Error( + 'OpenAI-compatible transcription request failed (401 Unauthorized): {"error":"invalid key"}' + ); + expect(classifyModelProbeError(error).reason).toBe("auth"); + }); + + test("prefers the provider message from the response body", () => { + const result = classifyModelProbeError( + apiCallError({ + statusCode: 404, + responseBody: JSON.stringify({ error: { message: "The model `nope` does not exist" } }), + }) + ); + expect(result.message).toBe("The model `nope` does not exist"); + }); + + test("truncates long messages", () => { + const result = classifyModelProbeError(new Error("x".repeat(1_000))); + expect(result.message.length).toBeLessThanOrEqual(300); + }); + + test("classifies unrecognized errors as unknown", () => { + expect(classifyModelProbeError(new Error("something odd")).reason).toBe("unknown"); + }); +}); + +describe("probeModelConfiguration", () => { + test("reports success when the text probe request succeeds", async () => { + generateTextMock.mockClear(); + generateTextMock.mockResolvedValueOnce({ text: "pong" }); + + const result = await probeModelConfiguration(TEXT_PROBE_INPUT); + + expect(result).toEqual({ ok: true }); + expect(generateTextMock).toHaveBeenCalledTimes(1); + }); + + test("classifies provider failures instead of throwing", async () => { + generateTextMock.mockRejectedValueOnce(apiCallError({ statusCode: 401 })); + + const result = await probeModelConfiguration(TEXT_PROBE_INPUT); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.reason).toBe("auth"); + } + }); + + test("embeds a test string for embedding models", async () => { + embedMock.mockClear(); + + const result = await probeModelConfiguration({ + type: "embedding", + adapter: "openaiAPI", + providerModel: "test-embedding", + credentials: { apiKey: "test-key", url: "https://provider.example/v1" }, + }); + + expect(result).toEqual({ ok: true }); + expect(embedMock).toHaveBeenCalledTimes(1); + }); + +}); + +describe("buildProbeWav", () => { + test("produces a valid non-empty WAV payload", () => { + const wav = buildProbeWav(); + const view = new DataView(wav.buffer); + + expect(new TextDecoder().decode(wav.slice(0, 4))).toBe("RIFF"); + expect(new TextDecoder().decode(wav.slice(8, 12))).toBe("WAVE"); + // Declared data size matches the payload after the 44-byte header. + expect(view.getUint32(40, true)).toBe(wav.byteLength - 44); + expect(wav.byteLength).toBeGreaterThan(44); + }); +}); diff --git a/packages/ai/src/probe.ts b/packages/ai/src/probe.ts new file mode 100644 index 00000000..20d468d3 --- /dev/null +++ b/packages/ai/src/probe.ts @@ -0,0 +1,223 @@ +import { APICallError } from "@ai-sdk/provider"; +import type { ModelTestResult } from "@kiwi/contracts/routes"; +import type { AiModelAdapter, AiModelType } from "@kiwi/db/tables/models"; +// Namespace import: test files mock the "ai" module with partial export sets, +// and named imports fail link-time validation against those mocks. +import * as ai from "ai"; +import { buildAdapter, buildEmbeddingAdapter } from "./chat"; +import { getClient, type EmbeddingAdapter } from "./index"; +import type { ModelCredentials } from "./models"; + +const PROBE_TIMEOUT_MS = 15_000; +const PROBE_MAX_OUTPUT_TOKENS = 16; +const PROBE_TEXT = "ping"; +const ERROR_MESSAGE_MAX_LENGTH = 300; + +export type ModelProbeInput = { + type: AiModelType; + adapter: AiModelAdapter; + providerModel: string; + credentials: ModelCredentials; +}; + +/** + * Runs a minimal, type-appropriate request against the provider to verify the + * supplied configuration without persisting anything. Never throws for + * provider failures; those are classified into a ModelTestResult. + */ +export async function probeModelConfiguration( + input: ModelProbeInput, + options?: { timeoutMs?: number } +): Promise { + const abortSignal = AbortSignal.timeout(options?.timeoutMs ?? PROBE_TIMEOUT_MS); + + try { + await runProbeRequest(input, abortSignal); + return { ok: true }; + } catch (error) { + return classifyModelProbeError(error); + } +} + +async function runProbeRequest(input: ModelProbeInput, abortSignal: AbortSignal): Promise { + const { adapter, providerModel, credentials } = input; + + switch (input.type) { + case "embedding": { + const client = getClient({ + embedding: buildEmbeddingAdapter( + adapter as EmbeddingAdapter["type"], + providerModel, + credentials.apiKey, + credentials.url, + credentials.resourceName + ), + }); + await ai.embed({ model: client.embedding!, value: PROBE_TEXT, maxRetries: 0, abortSignal }); + return; + } + // Video models are transcription endpoints in this system (see + // createTranscriptionModel), and those accept audio payloads, so the + // silent WAV works as a probe for both types. + case "audio": + case "video": { + const transcriptionAdapter = buildAdapter( + adapter, + providerModel, + credentials.apiKey, + credentials.url, + credentials.resourceName + ); + const client = getClient( + input.type === "audio" ? { audio: transcriptionAdapter } : { video: transcriptionAdapter } + ); + const model = (input.type === "audio" ? client.audio : client.video)!; + // doGenerate directly instead of ai.transcribe: the wrapper only + // adds retries, which the probe disables anyway. + await model.doGenerate({ + audio: buildProbeWav(), + mediaType: "audio/wav", + abortSignal, + }); + return; + } + // text, subagent, extract, and image models are all chat-completion + // language models here (image means multimodal understanding, see + // getClient), so a tiny text completion probes each of them. + default: { + const client = getClient({ + text: buildAdapter(adapter, providerModel, credentials.apiKey, credentials.url, credentials.resourceName), + }); + await ai.generateText({ + model: client.text!, + prompt: PROBE_TEXT, + maxOutputTokens: PROBE_MAX_OUTPUT_TOKENS, + maxRetries: 0, + abortSignal, + }); + } + } +} + +export function classifyModelProbeError(error: unknown): ModelTestResult & { ok: false } { + const message = extractErrorMessage(error); + const statusCode = extractStatusCode(error); + + if (statusCode === 401 || statusCode === 403) { + return { ok: false, reason: "auth", message }; + } + + if (statusCode === 404) { + return { ok: false, reason: "not_found", message }; + } + + if (statusCode !== undefined) { + return { ok: false, reason: "unknown", message }; + } + + if (isUnreachableError(error)) { + return { ok: false, reason: "unreachable", message }; + } + + return { ok: false, reason: "unknown", message }; +} + +function extractStatusCode(error: unknown): number | undefined { + if (APICallError.isInstance(error)) { + return error.statusCode; + } + + // OpenAICompatibleTranscriptionModel throws plain errors of the form + // "... transcription request failed (401 Unauthorized): ...". + if (error instanceof Error) { + const match = /transcription request failed \((\d{3}) /.exec(error.message); + if (match) { + return Number(match[1]); + } + } + + return undefined; +} + +function isUnreachableError(error: unknown): boolean { + if (error instanceof DOMException && (error.name === "TimeoutError" || error.name === "AbortError")) { + return true; + } + + if (!(error instanceof Error)) { + return false; + } + + if (APICallError.isInstance(error) && error.statusCode === undefined) { + return true; + } + + const cause = error.cause; + const causeCode = + cause && typeof cause === "object" && "code" in cause && typeof cause.code === "string" ? cause.code : undefined; + if (causeCode && ["ECONNREFUSED", "ENOTFOUND", "ECONNRESET", "ETIMEDOUT", "EAI_AGAIN"].includes(causeCode)) { + return true; + } + + return /fetch failed|unable to connect|network (error|failure|request failed)|socket|timed? ?out/i.test( + error.message + ); +} + +function extractErrorMessage(error: unknown): string { + const message = APICallError.isInstance(error) + ? (extractResponseBodyMessage(error.responseBody) ?? error.message) + : error instanceof Error + ? error.message + : String(error); + const normalized = message.replace(/\s+/g, " ").trim(); + + return normalized.length > ERROR_MESSAGE_MAX_LENGTH + ? `${normalized.slice(0, ERROR_MESSAGE_MAX_LENGTH - 1)}…` + : normalized; +} + +function extractResponseBodyMessage(responseBody: string | undefined): string | undefined { + if (!responseBody) { + return undefined; + } + + try { + const parsed = JSON.parse(responseBody) as { error?: { message?: unknown }; message?: unknown }; + const message = parsed.error?.message ?? parsed.message; + return typeof message === "string" && message.trim() ? message : undefined; + } catch { + return undefined; + } +} + +// Minimal valid WAV (0.5 s of 8 kHz mono 16-bit silence) so transcription +// probes send a payload every provider accepts without shipping a fixture. +export function buildProbeWav(): Uint8Array { + const sampleRate = 8_000; + const sampleCount = sampleRate / 2; + const dataSize = sampleCount * 2; + const buffer = new ArrayBuffer(44 + dataSize); + const view = new DataView(buffer); + const writeAscii = (offset: number, text: string) => { + for (let index = 0; index < text.length; index += 1) { + view.setUint8(offset + index, text.charCodeAt(index)); + } + }; + + writeAscii(0, "RIFF"); + view.setUint32(4, 36 + dataSize, true); + writeAscii(8, "WAVE"); + writeAscii(12, "fmt "); + view.setUint32(16, 16, true); + view.setUint16(20, 1, true); + view.setUint16(22, 1, true); + view.setUint32(24, sampleRate, true); + view.setUint32(28, sampleRate * 2, true); + view.setUint16(32, 2, true); + view.setUint16(34, 16, true); + writeAscii(36, "data"); + view.setUint32(40, dataSize, true); + + return new Uint8Array(buffer); +} diff --git a/packages/contracts/src/routes.ts b/packages/contracts/src/routes.ts index 93ef44f2..ffce25b5 100644 --- a/packages/contracts/src/routes.ts +++ b/packages/contracts/src/routes.ts @@ -301,6 +301,27 @@ export type ModelPatchInput = { credentials?: ModelCredentialsPatchInput; }; +export const MODEL_TEST_FAILURE_REASON_VALUES = ["auth", "not_found", "unreachable", "unknown"] as const; +export type ModelTestFailureReason = (typeof MODEL_TEST_FAILURE_REASON_VALUES)[number]; + +export type ModelTestResult = { ok: true } | { ok: false; reason: ModelTestFailureReason; message: string }; + +// Like ModelCredentialsPatchInput, the API key is optional: when omitted the +// backend reuses the stored key of the model referenced by model_id. +export type ModelTestCredentialsInput = { + apiKey?: string; + url?: string; + resourceName?: string; +}; + +export type ModelTestInput = { + model_id?: string; + type: AiModelType; + adapter: AiModelAdapter; + provider_model: string; + credentials: ModelTestCredentialsInput; +}; + export type FileTypeConfigRecord = { file_type: FileTypeValue; loader: string;
+ + {connectionError.text} +
{connectionError.detail}