diff --git a/artifacts/api-server/src/routes/providers.ts b/artifacts/api-server/src/routes/providers.ts index f795542..1e5a3ca 100644 --- a/artifacts/api-server/src/routes/providers.ts +++ b/artifacts/api-server/src/routes/providers.ts @@ -11,6 +11,7 @@ import { DeleteProviderParams, TestProviderParams, TestProviderResponse, + TestProviderConnectionBody, } from "@workspace/api-zod"; import { callProvider } from "../lib/aiAnalysis"; @@ -141,4 +142,63 @@ router.post("/providers/:id/test", async (req, res) => { } }); +router.post("/providers/test-connection", async (req, res) => { + const parsed = TestProviderConnectionBody.safeParse(req.body); + if (!parsed.success) + return res + .status(400) + .json({ message: "Ungültige Eingabe", details: parsed.error.issues }); + const d = parsed.data; + + let token: string | null = + d.apiToken && d.apiToken !== "" ? d.apiToken : null; + if (!token && d.providerId !== undefined && d.providerId !== null) { + const [existing] = await db + .select() + .from(aiProvidersTable) + .where(eq(aiProvidersTable.id, d.providerId)); + if (existing?.apiToken) token = existing.apiToken; + } + if (!token) { + return res.json( + TestProviderResponse.parse({ + ok: false, + message: "Kein API-Token angegeben.", + }), + ); + } + + const provider: AiProvider = { + id: d.providerId ?? 0, + name: "", + apiType: d.apiType, + baseUrl: d.baseUrl, + model: d.model, + apiToken: token, + enabled: true, + createdAt: new Date(), + }; + + try { + const reply = await callProvider( + provider, + "Du bist ein Verbindungstest.", + 'Antworte mit dem einzelnen Wort "OK".', + ); + return res.json( + TestProviderResponse.parse({ + ok: true, + message: `Verbindung erfolgreich. Antwort: ${reply.trim().slice(0, 80) || "(leer)"}`, + }), + ); + } catch (err) { + return res.json( + TestProviderResponse.parse({ + ok: false, + message: err instanceof Error ? err.message : "Verbindung fehlgeschlagen.", + }), + ); + } +}); + export default router; diff --git a/artifacts/skillguard/src/pages/admin.tsx b/artifacts/skillguard/src/pages/admin.tsx index 1fa3f71..42f85c4 100644 --- a/artifacts/skillguard/src/pages/admin.tsx +++ b/artifacts/skillguard/src/pages/admin.tsx @@ -1,7 +1,7 @@ import { useState } from "react"; import { useQueryClient } from "@tanstack/react-query"; import { - useListProviders, getListProvidersQueryKey, useCreateProvider, useUpdateProvider, useDeleteProvider, useTestProvider, + useListProviders, getListProvidersQueryKey, useCreateProvider, useUpdateProvider, useDeleteProvider, useTestProvider, useTestProviderConnection, useListPrompts, getListPromptsQueryKey, useUpdatePrompt, useListRules, getListRulesQueryKey, useUpdateRule, AiProviderApiType, RuleUpdateSeverity @@ -30,13 +30,18 @@ function ProviderTab() { const updateProvider = useUpdateProvider(); const deleteProvider = useDeleteProvider(); const testProvider = useTestProvider(); + const testConnection = useTestProviderConnection(); const [isAddOpen, setIsAddOpen] = useState(false); const [addForm, setAddForm] = useState({ name: "", apiType: AiProviderApiType.openai as AiProviderApiType, baseUrl: "", model: "", apiToken: "", enabled: true }); - + const [addTesting, setAddTesting] = useState(false); + const [addTestResult, setAddTestResult] = useState<{ ok: boolean; message: string } | null>(null); + const [editingId, setEditingId] = useState(null); const [editForm, setEditForm] = useState({ name: "", apiType: AiProviderApiType.openai as AiProviderApiType, baseUrl: "", model: "", apiToken: "", enabled: true }); - + const [editTesting, setEditTesting] = useState(false); + const [editTestResult, setEditTestResult] = useState<{ ok: boolean; message: string } | null>(null); + const [testingId, setTestingId] = useState(null); const invalidate = () => queryClient.invalidateQueries({ queryKey: getListProvidersQueryKey() }); @@ -99,6 +104,37 @@ function ProviderTab() { }); }; + const handleAddTest = () => { + setAddTestResult(null); + setAddTesting(true); + testConnection.mutate({ data: { apiType: addForm.apiType, baseUrl: addForm.baseUrl, model: addForm.model, apiToken: addForm.apiToken } }, { + onSuccess: (res) => { + setAddTesting(false); + setAddTestResult({ ok: res.ok, message: res.message || (res.ok ? "Der API-Aufruf war erfolgreich." : "Es gab ein Problem.") }); + }, + onError: () => { + setAddTesting(false); + setAddTestResult({ ok: false, message: "Verbindungstest konnte nicht durchgeführt werden." }); + } + }); + }; + + const handleEditTest = () => { + if (!editingId) return; + setEditTestResult(null); + setEditTesting(true); + testConnection.mutate({ data: { apiType: editForm.apiType, baseUrl: editForm.baseUrl, model: editForm.model, apiToken: editForm.apiToken, providerId: editingId } }, { + onSuccess: (res) => { + setEditTesting(false); + setEditTestResult({ ok: res.ok, message: res.message || (res.ok ? "Der API-Aufruf war erfolgreich." : "Es gab ein Problem.") }); + }, + onError: () => { + setEditTesting(false); + setEditTestResult({ ok: false, message: "Verbindungstest konnte nicht durchgeführt werden." }); + } + }); + }; + const openEdit = (provider: any) => { setEditForm({ name: provider.name, @@ -108,6 +144,8 @@ function ProviderTab() { apiToken: "", enabled: provider.enabled }); + setEditTestResult(null); + setEditTesting(false); setEditingId(provider.id); }; @@ -120,7 +158,7 @@ function ProviderTab() {

KI-Provider

Konfigurieren Sie externe LLM-Provider für die semantische Analyse.

- + { setIsAddOpen(o); if (!o) { setAddTestResult(null); setAddTesting(false); } }}> @@ -165,8 +203,18 @@ function ProviderTab() { setAddForm({...addForm, enabled: c})} /> + {addTestResult && ( +
+ {addTestResult.ok ? : } + {addTestResult.message} +
+ )} - + + @@ -252,8 +300,18 @@ function ProviderTab() { setEditForm({...editForm, apiToken: e.target.value})} placeholder="Token beibehalten" /> + {editTestResult && ( +
+ {editTestResult.ok ? : } + {editTestResult.message} +
+ )} - + + diff --git a/lib/api-client-react/src/generated/api.schemas.ts b/lib/api-client-react/src/generated/api.schemas.ts index 754c796..a8a222a 100644 --- a/lib/api-client-react/src/generated/api.schemas.ts +++ b/lib/api-client-react/src/generated/api.schemas.ts @@ -303,6 +303,27 @@ export interface ProviderTestResult { message?: string | null; } +export type ProviderTestConnectionInputApiType = typeof ProviderTestConnectionInputApiType[keyof typeof ProviderTestConnectionInputApiType]; + + +export const ProviderTestConnectionInputApiType = { + openai: 'openai', + anthropic: 'anthropic', + custom: 'custom', +} as const; + +export interface ProviderTestConnectionInput { + apiType: ProviderTestConnectionInputApiType; + /** @minLength 1 */ + baseUrl: string; + /** @minLength 1 */ + model: string; + /** Token to use for the test; omit or leave empty to fall back to the stored token of providerId */ + apiToken?: string; + /** When apiToken is empty, fall back to this saved provider's stored token */ + providerId?: number; +} + export interface Prompt { id: number; key: string; diff --git a/lib/api-client-react/src/generated/api.ts b/lib/api-client-react/src/generated/api.ts index 4c7fa4b..e0c5bce 100644 --- a/lib/api-client-react/src/generated/api.ts +++ b/lib/api-client-react/src/generated/api.ts @@ -28,6 +28,7 @@ import type { HealthStatus, Prompt, PromptUpdate, + ProviderTestConnectionInput, ProviderTestResult, Rule, RuleUpdate, @@ -860,6 +861,77 @@ export const useTestProvider = , return useMutation(getTestProviderMutationOptions(options)); } +export const getTestProviderConnectionUrl = () => { + + + + + return `/api/providers/test-connection` +} + +/** + * @summary Test a provider connection with ad-hoc configuration + */ +export const testProviderConnection = async (providerTestConnectionInput: ProviderTestConnectionInput, options?: RequestInit): Promise => { + + return customFetch(getTestProviderConnectionUrl(), + { + ...options, + method: 'POST', + headers: { 'Content-Type': 'application/json', ...options?.headers }, + body: JSON.stringify( + providerTestConnectionInput,) + } +);} + + + + +export const getTestProviderConnectionMutationOptions = , + TContext = unknown>(options?: { mutation?:UseMutationOptions>, TError,{data: BodyType}, TContext>, request?: SecondParameter} +): UseMutationOptions>, TError,{data: BodyType}, TContext> => { + +const mutationKey = ['testProviderConnection']; +const {mutation: mutationOptions, request: requestOptions} = options ? + options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ? + options + : {...options, mutation: {...options.mutation, mutationKey}} + : {mutation: { mutationKey, }, request: undefined}; + + + + + const mutationFn: MutationFunction>, {data: BodyType}> = (props) => { + const {data} = props ?? {}; + + return testProviderConnection(data,requestOptions) + } + + + + + + + return { mutationFn, ...mutationOptions }} + + export type TestProviderConnectionMutationResult = NonNullable>> + export type TestProviderConnectionMutationBody = BodyType + export type TestProviderConnectionMutationError = ErrorType + + /** + * @summary Test a provider connection with ad-hoc configuration + */ +export const useTestProviderConnection = , + TContext = unknown>(options?: { mutation?:UseMutationOptions>, TError,{data: BodyType}, TContext>, request?: SecondParameter} + ): UseMutationResult< + Awaited>, + TError, + {data: BodyType}, + TContext + > => { + return useMutation(getTestProviderConnectionMutationOptions(options)); + } + export const getListPromptsUrl = () => { diff --git a/lib/api-spec/openapi.yaml b/lib/api-spec/openapi.yaml index 685de61..e8b92e1 100644 --- a/lib/api-spec/openapi.yaml +++ b/lib/api-spec/openapi.yaml @@ -218,6 +218,25 @@ paths: schema: $ref: "#/components/schemas/ProviderTestResult" + /providers/test-connection: + post: + operationId: testProviderConnection + tags: [providers] + summary: Test a provider connection with ad-hoc configuration + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/ProviderTestConnectionInput" + responses: + "200": + description: Test result + content: + application/json: + schema: + $ref: "#/components/schemas/ProviderTestResult" + /prompts: get: operationId: listPrompts @@ -584,6 +603,26 @@ components: message: type: ["string", "null"] + ProviderTestConnectionInput: + type: object + required: [apiType, baseUrl, model] + properties: + apiType: + type: string + enum: [openai, anthropic, custom] + baseUrl: + type: string + minLength: 1 + model: + type: string + minLength: 1 + apiToken: + type: string + description: Token to use for the test; omit or leave empty to fall back to the stored token of providerId + providerId: + type: integer + description: When apiToken is empty, fall back to this saved provider's stored token + Prompt: type: object required: [id, key, name, content, updatedAt] diff --git a/lib/api-zod/src/generated/api.ts b/lib/api-zod/src/generated/api.ts index aee9891..6703cf4 100644 --- a/lib/api-zod/src/generated/api.ts +++ b/lib/api-zod/src/generated/api.ts @@ -273,6 +273,27 @@ export const TestProviderResponse = zod.object({ }) +/** + * @summary Test a provider connection with ad-hoc configuration + */ + + + + +export const TestProviderConnectionBody = zod.object({ + "apiType": zod.enum(['openai', 'anthropic', 'custom']), + "baseUrl": zod.string().min(1), + "model": zod.string().min(1), + "apiToken": zod.string().optional().describe('Token to use for the test; omit or leave empty to fall back to the stored token of providerId'), + "providerId": zod.number().optional().describe('When apiToken is empty, fall back to this saved provider\'s stored token') +}) + +export const TestProviderConnectionResponse = zod.object({ + "ok": zod.boolean(), + "message": zod.string().nullish() +}) + + /** * @summary List configurable AI prompts */ diff --git a/lib/api-zod/src/generated/types/index.ts b/lib/api-zod/src/generated/types/index.ts index 36aef66..77e8127 100644 --- a/lib/api-zod/src/generated/types/index.ts +++ b/lib/api-zod/src/generated/types/index.ts @@ -23,6 +23,8 @@ export * from './findingSeverity'; export * from './healthStatus'; export * from './prompt'; export * from './promptUpdate'; +export * from './providerTestConnectionInput'; +export * from './providerTestConnectionInputApiType'; export * from './providerTestResult'; export * from './rule'; export * from './ruleAxis'; diff --git a/lib/api-zod/src/generated/types/providerTestConnectionInput.ts b/lib/api-zod/src/generated/types/providerTestConnectionInput.ts new file mode 100644 index 0000000..5104c98 --- /dev/null +++ b/lib/api-zod/src/generated/types/providerTestConnectionInput.ts @@ -0,0 +1,20 @@ +/** + * Generated by orval v8.9.1 🍺 + * Do not edit manually. + * Api + * API specification + * OpenAPI spec version: 0.1.0 + */ +import type { ProviderTestConnectionInputApiType } from './providerTestConnectionInputApiType'; + +export interface ProviderTestConnectionInput { + apiType: ProviderTestConnectionInputApiType; + /** @minLength 1 */ + baseUrl: string; + /** @minLength 1 */ + model: string; + /** Token to use for the test; omit or leave empty to fall back to the stored token of providerId */ + apiToken?: string; + /** When apiToken is empty, fall back to this saved provider's stored token */ + providerId?: number; +} diff --git a/lib/api-zod/src/generated/types/providerTestConnectionInputApiType.ts b/lib/api-zod/src/generated/types/providerTestConnectionInputApiType.ts new file mode 100644 index 0000000..e4b0ff3 --- /dev/null +++ b/lib/api-zod/src/generated/types/providerTestConnectionInputApiType.ts @@ -0,0 +1,16 @@ +/** + * Generated by orval v8.9.1 🍺 + * Do not edit manually. + * Api + * API specification + * OpenAPI spec version: 0.1.0 + */ + +export type ProviderTestConnectionInputApiType = typeof ProviderTestConnectionInputApiType[keyof typeof ProviderTestConnectionInputApiType]; + + +export const ProviderTestConnectionInputApiType = { + openai: 'openai', + anthropic: 'anthropic', + custom: 'custom', +} as const;