@flowselections/ai-authenticatie 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (159) hide show
  1. package/dist-lib/_core-safelist.d.ts +2 -0
  2. package/dist-lib/_core-safelist.d.ts.map +1 -0
  3. package/dist-lib/_core-safelist.js +15 -0
  4. package/dist-lib/components/AiAuthenticatieLogo.d.ts +3 -0
  5. package/dist-lib/components/AiAuthenticatieLogo.d.ts.map +1 -0
  6. package/dist-lib/components/AiAuthenticatieLogo.js +4 -0
  7. package/dist-lib/components/AiAuthenticatiePage.d.ts +4 -0
  8. package/dist-lib/components/AiAuthenticatiePage.d.ts.map +1 -0
  9. package/dist-lib/components/AiAuthenticatiePage.js +145 -0
  10. package/dist-lib/components/AiModuleDetailPage.d.ts +2 -0
  11. package/dist-lib/components/AiModuleDetailPage.d.ts.map +1 -0
  12. package/dist-lib/components/AiModuleDetailPage.js +43 -0
  13. package/dist-lib/components/AiModuleTestPanel.d.ts +4 -0
  14. package/dist-lib/components/AiModuleTestPanel.d.ts.map +1 -0
  15. package/dist-lib/components/AiModuleTestPanel.js +38 -0
  16. package/dist-lib/components/AiPeriodFilter.d.ts +7 -0
  17. package/dist-lib/components/AiPeriodFilter.d.ts.map +1 -0
  18. package/dist-lib/components/AiPeriodFilter.js +14 -0
  19. package/dist-lib/components/AiStatTile.d.ts +6 -0
  20. package/dist-lib/components/AiStatTile.d.ts.map +1 -0
  21. package/dist-lib/components/AiStatTile.js +5 -0
  22. package/dist-lib/components/ModuleAgentsDialog.d.ts +14 -0
  23. package/dist-lib/components/ModuleAgentsDialog.d.ts.map +1 -0
  24. package/dist-lib/components/ModuleAgentsDialog.js +78 -0
  25. package/dist-lib/components/ProviderLogo.d.ts +8 -0
  26. package/dist-lib/components/ProviderLogo.d.ts.map +1 -0
  27. package/dist-lib/components/ProviderLogo.js +24 -0
  28. package/dist-lib/components/ProviderManageDialog.d.ts +11 -0
  29. package/dist-lib/components/ProviderManageDialog.d.ts.map +1 -0
  30. package/dist-lib/components/ProviderManageDialog.js +173 -0
  31. package/dist-lib/components/settings/AiAuthenticatieSettingsCard.d.ts +2 -0
  32. package/dist-lib/components/settings/AiAuthenticatieSettingsCard.d.ts.map +1 -0
  33. package/dist-lib/components/settings/AiAuthenticatieSettingsCard.js +7 -0
  34. package/dist-lib/components/settings/MijnModuleSettingsCard.d.ts +2 -0
  35. package/dist-lib/components/settings/MijnModuleSettingsCard.d.ts.map +1 -0
  36. package/dist-lib/components/settings/MijnModuleSettingsCard.js +42 -0
  37. package/dist-lib/hooks/useAdminCredentials.d.ts +14 -0
  38. package/dist-lib/hooks/useAdminCredentials.d.ts.map +1 -0
  39. package/dist-lib/hooks/useAdminCredentials.js +17 -0
  40. package/dist-lib/hooks/useAiAdmin.d.ts +44 -0
  41. package/dist-lib/hooks/useAiAdmin.d.ts.map +1 -0
  42. package/dist-lib/hooks/useAiAdmin.js +32 -0
  43. package/dist-lib/hooks/useAiCostOverview.d.ts +7 -0
  44. package/dist-lib/hooks/useAiCostOverview.d.ts.map +1 -0
  45. package/dist-lib/hooks/useAiCostOverview.js +28 -0
  46. package/dist-lib/hooks/useAiForGoal.d.ts +21 -0
  47. package/dist-lib/hooks/useAiForGoal.d.ts.map +1 -0
  48. package/dist-lib/hooks/useAiForGoal.js +40 -0
  49. package/dist-lib/hooks/useAiForModule.d.ts +11 -0
  50. package/dist-lib/hooks/useAiForModule.d.ts.map +1 -0
  51. package/dist-lib/hooks/useAiForModule.js +11 -0
  52. package/dist-lib/hooks/useAiGoals.d.ts +8 -0
  53. package/dist-lib/hooks/useAiGoals.d.ts.map +1 -0
  54. package/dist-lib/hooks/useAiGoals.js +22 -0
  55. package/dist-lib/hooks/useAiModules.d.ts +15 -0
  56. package/dist-lib/hooks/useAiModules.d.ts.map +1 -0
  57. package/dist-lib/hooks/useAiModules.js +59 -0
  58. package/dist-lib/hooks/useAiPricing.d.ts +20 -0
  59. package/dist-lib/hooks/useAiPricing.d.ts.map +1 -0
  60. package/dist-lib/hooks/useAiPricing.js +97 -0
  61. package/dist-lib/hooks/useAiUsage.d.ts +12 -0
  62. package/dist-lib/hooks/useAiUsage.d.ts.map +1 -0
  63. package/dist-lib/hooks/useAiUsage.js +90 -0
  64. package/dist-lib/hooks/useAiUsageByModule.d.ts +8 -0
  65. package/dist-lib/hooks/useAiUsageByModule.d.ts.map +1 -0
  66. package/dist-lib/hooks/useAiUsageByModule.js +22 -0
  67. package/dist-lib/hooks/useIsAiAdmin.d.ts +6 -0
  68. package/dist-lib/hooks/useIsAiAdmin.d.ts.map +1 -0
  69. package/dist-lib/hooks/useIsAiAdmin.js +5 -0
  70. package/dist-lib/hooks/useMijnModuleData.d.ts +13 -0
  71. package/dist-lib/hooks/useMijnModuleData.d.ts.map +1 -0
  72. package/dist-lib/hooks/useMijnModuleData.js +49 -0
  73. package/dist-lib/hooks/useModules.d.ts +14 -0
  74. package/dist-lib/hooks/useModules.d.ts.map +1 -0
  75. package/dist-lib/hooks/useModules.js +47 -0
  76. package/dist-lib/index.d.ts +29 -0
  77. package/dist-lib/index.d.ts.map +1 -0
  78. package/dist-lib/index.js +33 -0
  79. package/dist-lib/integrations/supabase/auth-attacher.d.ts +2 -0
  80. package/dist-lib/integrations/supabase/auth-attacher.d.ts.map +1 -0
  81. package/dist-lib/integrations/supabase/auth-attacher.js +12 -0
  82. package/dist-lib/integrations/supabase/auth-middleware.d.ts +15971 -0
  83. package/dist-lib/integrations/supabase/auth-middleware.d.ts.map +1 -0
  84. package/dist-lib/integrations/supabase/auth-middleware.js +49 -0
  85. package/dist-lib/integrations/supabase/client.d.ts +15967 -0
  86. package/dist-lib/integrations/supabase/client.d.ts.map +1 -0
  87. package/dist-lib/integrations/supabase/client.js +13 -0
  88. package/dist-lib/integrations/supabase/client.server.d.ts +15967 -0
  89. package/dist-lib/integrations/supabase/client.server.d.ts.map +1 -0
  90. package/dist-lib/integrations/supabase/client.server.js +30 -0
  91. package/dist-lib/integrations/supabase/types.d.ts +16477 -0
  92. package/dist-lib/integrations/supabase/types.d.ts.map +1 -0
  93. package/dist-lib/integrations/supabase/types.js +78 -0
  94. package/dist-lib/lib/aiChat.functions.d.ts +31948 -0
  95. package/dist-lib/lib/aiChat.functions.d.ts.map +1 -0
  96. package/dist-lib/lib/aiChat.functions.js +51 -0
  97. package/dist-lib/lib/aiChat.server.d.ts +8 -0
  98. package/dist-lib/lib/aiChat.server.d.ts.map +1 -0
  99. package/dist-lib/lib/aiChat.server.js +347 -0
  100. package/dist-lib/lib/aiChat.stream.d.ts +33 -0
  101. package/dist-lib/lib/aiChat.stream.d.ts.map +1 -0
  102. package/dist-lib/lib/aiChat.stream.js +77 -0
  103. package/dist-lib/lib/aiChat.stream.server.d.ts +38 -0
  104. package/dist-lib/lib/aiChat.stream.server.d.ts.map +1 -0
  105. package/dist-lib/lib/aiChat.stream.server.js +259 -0
  106. package/dist-lib/lib/aiChat.types.d.ts +63 -0
  107. package/dist-lib/lib/aiChat.types.d.ts.map +1 -0
  108. package/dist-lib/lib/aiChat.types.js +1 -0
  109. package/dist-lib/lib/aiCredentialTest.functions.d.ts +31959 -0
  110. package/dist-lib/lib/aiCredentialTest.functions.d.ts.map +1 -0
  111. package/dist-lib/lib/aiCredentialTest.functions.js +714 -0
  112. package/dist-lib/lib/aiGoals.functions.d.ts +12912 -0
  113. package/dist-lib/lib/aiGoals.functions.d.ts.map +1 -0
  114. package/dist-lib/lib/aiGoals.functions.js +14 -0
  115. package/dist-lib/lib/aiModules.functions.d.ts +95851 -0
  116. package/dist-lib/lib/aiModules.functions.d.ts.map +1 -0
  117. package/dist-lib/lib/aiModules.functions.js +41 -0
  118. package/dist-lib/lib/aiModules.server.d.ts +65 -0
  119. package/dist-lib/lib/aiModules.server.d.ts.map +1 -0
  120. package/dist-lib/lib/aiModules.server.js +119 -0
  121. package/dist-lib/lib/aiPeriod.d.ts +11 -0
  122. package/dist-lib/lib/aiPeriod.d.ts.map +1 -0
  123. package/dist-lib/lib/aiPeriod.js +53 -0
  124. package/dist-lib/lib/aiPricing.functions.d.ts +31943 -0
  125. package/dist-lib/lib/aiPricing.functions.d.ts.map +1 -0
  126. package/dist-lib/lib/aiPricing.functions.js +13 -0
  127. package/dist-lib/lib/aiPricing.server.d.ts +19 -0
  128. package/dist-lib/lib/aiPricing.server.d.ts.map +1 -0
  129. package/dist-lib/lib/aiPricing.server.js +97 -0
  130. package/dist-lib/lib/aiUsage.functions.d.ts +5 -0
  131. package/dist-lib/lib/aiUsage.functions.d.ts.map +1 -0
  132. package/dist-lib/lib/aiUsage.functions.js +12 -0
  133. package/dist-lib/lib/aiUsage.server.d.ts +3 -0
  134. package/dist-lib/lib/aiUsage.server.d.ts.map +1 -0
  135. package/dist-lib/lib/aiUsage.server.js +229 -0
  136. package/dist-lib/lib/aiUsage.types.d.ts +21 -0
  137. package/dist-lib/lib/aiUsage.types.d.ts.map +1 -0
  138. package/dist-lib/lib/aiUsage.types.js +1 -0
  139. package/dist-lib/lib/aiUsageByModule.functions.d.ts +31952 -0
  140. package/dist-lib/lib/aiUsageByModule.functions.d.ts.map +1 -0
  141. package/dist-lib/lib/aiUsageByModule.functions.js +37 -0
  142. package/dist-lib/lib/modules.functions.d.ts +47924 -0
  143. package/dist-lib/lib/modules.functions.d.ts.map +1 -0
  144. package/dist-lib/lib/modules.functions.js +21 -0
  145. package/dist-lib/lib/modules.server.d.ts +23 -0
  146. package/dist-lib/lib/modules.server.d.ts.map +1 -0
  147. package/dist-lib/lib/modules.server.js +52 -0
  148. package/dist-lib/lib/utils.d.ts +3 -0
  149. package/dist-lib/lib/utils.d.ts.map +1 -0
  150. package/dist-lib/lib/utils.js +5 -0
  151. package/dist-lib/lib/validationSchemas.d.ts +15 -0
  152. package/dist-lib/lib/validationSchemas.d.ts.map +1 -0
  153. package/dist-lib/lib/validationSchemas.js +25 -0
  154. package/dist-lib/styles.css +1 -0
  155. package/package.json +92 -0
  156. package/public/flowselections-assets/ai-authenticatie/claude.png +0 -0
  157. package/public/flowselections-assets/ai-authenticatie/gemini.png +0 -0
  158. package/public/flowselections-assets/ai-authenticatie/openai.png +0 -0
  159. package/public/logo.png +0 -0
@@ -0,0 +1,714 @@
1
+ import { createServerFn } from '@tanstack/react-start';
2
+ import { requireSupabaseAuth } from '../integrations/supabase/auth-middleware';
3
+ const TIMEOUT_MS = 10000;
4
+ const GEMINI_DEFAULT_API_BASE = 'https://generativelanguage.googleapis.com/v1beta';
5
+ function withTimeout(ms = TIMEOUT_MS) {
6
+ const ctrl = new AbortController();
7
+ const t = setTimeout(() => ctrl.abort(), ms);
8
+ return { signal: ctrl.signal, done: () => clearTimeout(t) };
9
+ }
10
+ function safeParse(text) {
11
+ try {
12
+ return JSON.parse(text);
13
+ }
14
+ catch {
15
+ return null;
16
+ }
17
+ }
18
+ // ─── Gemini ──────────────────────────────────────────────────────────
19
+ // Alleen echte chat-/tekstmodellen: het model MOET generateContent ondersteunen.
20
+ const GEMINI_GENERATE_METHODS = ['generateContent'];
21
+ // Modellen die wel generateContent melden maar geen bruikbare chat leveren
22
+ // (beeld, spraak, live/bidi, robotics, embeddings, ...).
23
+ const GEMINI_EXCLUDE_TOKENS = [
24
+ '-image', 'image-', 'imagen', 'veo', '-tts', 'tts-', 'native-audio', 'audio',
25
+ '-live', 'live-', 'robotics', 'embedding', 'aqa', 'computer-use',
26
+ ];
27
+ function isGeminiChatModel(slug) {
28
+ const s = slug.toLowerCase();
29
+ if (GEMINI_EXCLUDE_TOKENS.some(t => s.includes(t)))
30
+ return false;
31
+ return s.startsWith('gemini-') || s.startsWith('gemma-');
32
+ }
33
+ function normalizeGeminiApiBase(baseUrl) {
34
+ const raw = (baseUrl || GEMINI_DEFAULT_API_BASE).trim().replace(/\/+$/, '');
35
+ if (!raw)
36
+ return GEMINI_DEFAULT_API_BASE;
37
+ try {
38
+ const url = new URL(raw);
39
+ const parts = url.pathname.split('/').filter(Boolean);
40
+ const versionIndex = parts.findIndex(part => /^v\d+(alpha|beta)?$/i.test(part));
41
+ const version = versionIndex >= 0 ? parts[versionIndex] : 'v1beta';
42
+ url.pathname = `/${version}`;
43
+ url.search = '';
44
+ url.hash = '';
45
+ return url.toString().replace(/\/+$/, '');
46
+ }
47
+ catch {
48
+ return GEMINI_DEFAULT_API_BASE;
49
+ }
50
+ }
51
+ function geminiModelsUrl(baseUrl, pageToken) {
52
+ const qs = new URLSearchParams({ pageSize: '200' });
53
+ if (pageToken)
54
+ qs.set('pageToken', pageToken);
55
+ return `${normalizeGeminiApiBase(baseUrl)}/models?${qs.toString()}`;
56
+ }
57
+ function geminiGenerateUrl(baseUrl, model) {
58
+ return `${normalizeGeminiApiBase(baseUrl)}/models/${encodeURIComponent(model)}:generateContent`;
59
+ }
60
+ function googleContextDetails(context) {
61
+ return [
62
+ context.endpoint ? `endpoint: ${context.endpoint}` : null,
63
+ context.model ? `model: ${context.model}` : null,
64
+ context.url ? `url: ${context.url}` : null,
65
+ ].filter(Boolean).join(', ');
66
+ }
67
+ function parseGoogleError(status, text, context = {}) {
68
+ const json = safeParse(text);
69
+ const msg = json?.error?.message;
70
+ const st = json?.error?.status;
71
+ const details = googleContextDetails(context);
72
+ if (msg) {
73
+ const base = st ? `${st}: ${msg}` : msg;
74
+ return status === 404 && details ? `${base} (${details})` : base;
75
+ }
76
+ if (status === 401 || status === 403)
77
+ return 'Google weigert de API-key. Controleer of de key geldig is en de Generative Language API is ingeschakeld.';
78
+ if (status === 429)
79
+ return 'Google rate-limit bereikt (429).';
80
+ if (status === 404) {
81
+ return `Google gaf 404 terug${details ? ` (${details})` : ''}. Controleer of de Gemini provider-base-url naar generativelanguage.googleapis.com wijst, of de Generative Language API is ingeschakeld en of de API-key toegang heeft tot dit endpoint/model.`;
82
+ }
83
+ return `Onverwachte fout van Google (${status}).`;
84
+ }
85
+ async function fetchGeminiModelsPage(apiKey, baseUrl, pageToken) {
86
+ const primaryUrl = geminiModelsUrl(baseUrl, pageToken);
87
+ const fallbackUrl = geminiModelsUrl(GEMINI_DEFAULT_API_BASE, pageToken);
88
+ const urls = primaryUrl === fallbackUrl ? [primaryUrl] : [primaryUrl, fallbackUrl];
89
+ for (const url of urls) {
90
+ const { signal, done } = withTimeout();
91
+ try {
92
+ const res = await fetch(url, { headers: { 'x-goog-api-key': apiKey }, signal });
93
+ const text = await res.text();
94
+ if (res.ok)
95
+ return { ok: true, status: res.status, text };
96
+ const message = parseGoogleError(res.status, text, { endpoint: 'models.list', url });
97
+ if (res.status !== 404 || url === urls[urls.length - 1])
98
+ return { ok: false, status: res.status, message };
99
+ }
100
+ catch (e) {
101
+ const msg = e instanceof Error && e.name === 'AbortError'
102
+ ? 'Timeout: Google reageerde niet binnen 10s.'
103
+ : `Netwerkfout: ${e instanceof Error ? e.message : 'onbekend'}`;
104
+ return { ok: false, status: 0, message: msg };
105
+ }
106
+ finally {
107
+ done();
108
+ }
109
+ }
110
+ return { ok: false, status: 404, message: 'Google gaf 404 terug bij het ophalen van modellen.' };
111
+ }
112
+ async function fetchGeminiModels(apiKey, baseUrl) {
113
+ const collected = [];
114
+ let pageToken = undefined;
115
+ let lastStatus = 0;
116
+ for (let page = 0; page < 5; page++) {
117
+ const pageRes = await fetchGeminiModelsPage(apiKey, baseUrl, pageToken);
118
+ lastStatus = pageRes.status;
119
+ if (!pageRes.ok)
120
+ return { ok: false, status: pageRes.status, message: pageRes.message };
121
+ const json = safeParse(pageRes.text);
122
+ const raw = Array.isArray(json?.models) ? json.models : [];
123
+ for (const m of raw) {
124
+ const methods = Array.isArray(m?.supportedGenerationMethods) ? m.supportedGenerationMethods : [];
125
+ if (!methods.some(x => GEMINI_GENERATE_METHODS.includes(x)))
126
+ continue;
127
+ const fullName = typeof m.name === 'string' ? m.name : '';
128
+ const slug = fullName.startsWith('models/') ? fullName.slice(7) : fullName;
129
+ if (!slug)
130
+ continue;
131
+ if (!isGeminiChatModel(slug))
132
+ continue;
133
+ collected.push({
134
+ slug,
135
+ display_name: typeof m.displayName === 'string' && m.displayName ? m.displayName : slug,
136
+ context_window: typeof m.inputTokenLimit === 'number' ? m.inputTokenLimit : null,
137
+ methods,
138
+ });
139
+ }
140
+ pageToken = typeof json?.nextPageToken === 'string' && json.nextPageToken ? json.nextPageToken : undefined;
141
+ if (!pageToken)
142
+ break;
143
+ }
144
+ return { ok: true, status: lastStatus || 200, models: collected };
145
+ }
146
+ async function verifyGeminiGenerate(apiKey, baseUrl, models) {
147
+ const capable = models.filter(m => Array.isArray(m.methods) && m.methods.includes('generateContent'));
148
+ if (capable.length === 0) {
149
+ return { ok: true, skipped: true, message: `Modellen opgehaald (${models.length}), maar geen enkel model op deze key ondersteunt generateContent — verificatie overgeslagen.` };
150
+ }
151
+ const preferred = ['gemini-3.5-flash', 'gemini-3-flash-preview', 'gemini-2.5-flash', 'gemini-2.0-flash', 'gemini-1.5-flash'];
152
+ const slugs = capable.map(m => m.slug);
153
+ const chosen = preferred.find(p => slugs.includes(p))
154
+ ?? slugs.find(s => s.includes('flash'))
155
+ ?? slugs.find(s => s.startsWith('gemini-'))
156
+ ?? slugs[0];
157
+ const url = geminiGenerateUrl(baseUrl, chosen);
158
+ const { signal, done } = withTimeout();
159
+ try {
160
+ const res = await fetch(url, {
161
+ method: 'POST',
162
+ headers: { 'x-goog-api-key': apiKey, 'Content-Type': 'application/json' },
163
+ body: JSON.stringify({
164
+ contents: [{ parts: [{ text: 'ping' }] }],
165
+ generationConfig: { maxOutputTokens: 1 },
166
+ }),
167
+ signal,
168
+ });
169
+ const text = await res.text();
170
+ if (!res.ok) {
171
+ const parsed = parseGoogleError(res.status, text, { endpoint: 'models.generateContent', model: chosen, url });
172
+ const hint = `generateContent (${chosen}) → ${parsed}`;
173
+ return { ok: false, status: res.status, message: hint };
174
+ }
175
+ return { ok: true };
176
+ }
177
+ catch (e) {
178
+ const msg = e instanceof Error && e.name === 'AbortError'
179
+ ? 'Timeout: Google reageerde niet binnen 10s tijdens generateContent-check.'
180
+ : `Netwerkfout tijdens generateContent-check: ${e instanceof Error ? e.message : 'onbekend'}`;
181
+ return { ok: false, status: 0, message: msg };
182
+ }
183
+ finally {
184
+ done();
185
+ }
186
+ }
187
+ /**
188
+ * Google's ListModels geeft ook modellen terug die voor déze key zijn
189
+ * uitgefaseerd ("no longer available to new users"). Alleen een echte
190
+ * generateContent-aanroep laat dat zien. We pingen daarom elk model met
191
+ * maxOutputTokens=1 en gooien alleen 404-modellen weg. 429/503/400 zijn
192
+ * tijdelijk of inputgerelateerd → model blijft behouden.
193
+ */
194
+ async function probeGeminiAvailability(apiKey, baseUrl, models) {
195
+ const CONCURRENCY = 5;
196
+ const unavailable = new Set();
197
+ const queue = [...models];
198
+ const worker = async () => {
199
+ for (;;) {
200
+ const m = queue.shift();
201
+ if (!m)
202
+ return;
203
+ const { signal, done } = withTimeout(15000);
204
+ try {
205
+ const res = await fetch(geminiGenerateUrl(baseUrl, m.slug), {
206
+ method: 'POST',
207
+ headers: { 'x-goog-api-key': apiKey, 'Content-Type': 'application/json' },
208
+ body: JSON.stringify({
209
+ contents: [{ parts: [{ text: 'ping' }] }],
210
+ generationConfig: { maxOutputTokens: 1 },
211
+ }),
212
+ signal,
213
+ });
214
+ if (res.status === 404)
215
+ unavailable.add(m.slug);
216
+ }
217
+ catch {
218
+ // Netwerkfout/timeout: model niet uitsluiten.
219
+ }
220
+ finally {
221
+ done();
222
+ }
223
+ }
224
+ };
225
+ await Promise.all(Array.from({ length: Math.min(CONCURRENCY, models.length) }, worker));
226
+ const usable = models.filter(m => !unavailable.has(m.slug));
227
+ return usable.length > 0 ? usable : models;
228
+ }
229
+ // ─── OpenAI ──────────────────────────────────────────────────────────
230
+ const OPENAI_DEFAULT_API_BASE = 'https://api.openai.com/v1';
231
+ const OPENAI_CHAT_PREFIXES = ['gpt-', 'chatgpt-', 'o1', 'o3', 'o4'];
232
+ const OPENAI_EXCLUDE_TOKENS = [
233
+ 'whisper', 'tts', 'audio', 'realtime', 'transcribe',
234
+ 'dall-e', 'image', 'embedding', 'moderation',
235
+ 'davinci', 'babbage', 'search', 'similarity', 'edit',
236
+ ];
237
+ const OPENAI_PREFERRED_MODELS = [
238
+ 'gpt-5-nano', 'gpt-5-mini', 'gpt-5.4-nano', 'gpt-5.4-mini',
239
+ 'gpt-4o-mini', 'gpt-4.1-nano', 'gpt-4.1-mini', 'gpt-4o',
240
+ ];
241
+ function normalizeOpenAIApiBase(baseUrl) {
242
+ const raw = (baseUrl || OPENAI_DEFAULT_API_BASE).trim().replace(/\/+$/, '');
243
+ if (!raw)
244
+ return OPENAI_DEFAULT_API_BASE;
245
+ try {
246
+ const url = new URL(raw);
247
+ const parts = url.pathname.split('/').filter(Boolean);
248
+ const versionIndex = parts.findIndex(p => /^v\d+$/i.test(p));
249
+ const version = versionIndex >= 0 ? parts[versionIndex] : 'v1';
250
+ url.pathname = `/${version}`;
251
+ url.search = '';
252
+ url.hash = '';
253
+ return url.toString().replace(/\/+$/, '');
254
+ }
255
+ catch {
256
+ return OPENAI_DEFAULT_API_BASE;
257
+ }
258
+ }
259
+ function openAIModelsUrl(baseUrl) {
260
+ return `${normalizeOpenAIApiBase(baseUrl)}/models`;
261
+ }
262
+ function openAIChatUrl(baseUrl) {
263
+ return `${normalizeOpenAIApiBase(baseUrl)}/chat/completions`;
264
+ }
265
+ function openAIContextDetails(ctx) {
266
+ return [
267
+ ctx.endpoint ? `endpoint: ${ctx.endpoint}` : null,
268
+ ctx.model ? `model: ${ctx.model}` : null,
269
+ ctx.url ? `url: ${ctx.url}` : null,
270
+ ].filter(Boolean).join(', ');
271
+ }
272
+ function parseOpenAIError(status, text, ctx = {}) {
273
+ const json = safeParse(text);
274
+ const msg = json?.error?.message;
275
+ const code = json?.error?.code || json?.error?.type;
276
+ const details = openAIContextDetails(ctx);
277
+ if (msg) {
278
+ const base = code ? `${code}: ${msg}` : msg;
279
+ return details ? `${base} (${details})` : base;
280
+ }
281
+ if (status === 401)
282
+ return 'OpenAI weigert de API-key (401). Controleer of de key geldig is.';
283
+ if (status === 403)
284
+ return 'OpenAI weigert toegang (403). De key heeft mogelijk geen toegang tot dit endpoint of de organization/project header ontbreekt.';
285
+ if (status === 404)
286
+ return `OpenAI gaf 404 terug${details ? ` (${details})` : ''}. Controleer de base URL, of het model bestaat voor deze key.`;
287
+ if (status === 429)
288
+ return 'OpenAI rate-limit of credits op (429). Wacht even of controleer je billing.';
289
+ if (status >= 500)
290
+ return `OpenAI upstream fout (${status}). Probeer het later opnieuw.`;
291
+ return `Onverwachte fout van OpenAI (${status}).`;
292
+ }
293
+ function isOpenAIChatModel(slug) {
294
+ const s = slug.toLowerCase();
295
+ if (OPENAI_EXCLUDE_TOKENS.some(t => s.includes(t)))
296
+ return false;
297
+ return OPENAI_CHAT_PREFIXES.some(p => s.startsWith(p));
298
+ }
299
+ async function fetchOpenAIModelsRequest(apiKey, url) {
300
+ const { signal, done } = withTimeout();
301
+ try {
302
+ const res = await fetch(url, { headers: { Authorization: `Bearer ${apiKey}` }, signal });
303
+ const text = await res.text();
304
+ if (!res.ok)
305
+ return { ok: false, status: res.status, message: parseOpenAIError(res.status, text, { endpoint: 'models.list', url }) };
306
+ return { ok: true, status: res.status, text };
307
+ }
308
+ catch (e) {
309
+ const msg = e instanceof Error && e.name === 'AbortError'
310
+ ? 'Timeout: OpenAI reageerde niet binnen 10s.'
311
+ : `Netwerkfout: ${e instanceof Error ? e.message : 'onbekend'}`;
312
+ return { ok: false, status: 0, message: msg };
313
+ }
314
+ finally {
315
+ done();
316
+ }
317
+ }
318
+ async function fetchOpenAIModels(apiKey, baseUrl) {
319
+ const primaryUrl = openAIModelsUrl(baseUrl);
320
+ const fallbackUrl = openAIModelsUrl(OPENAI_DEFAULT_API_BASE);
321
+ const urls = primaryUrl === fallbackUrl ? [primaryUrl] : [primaryUrl, fallbackUrl];
322
+ let last = null;
323
+ for (const url of urls) {
324
+ const res = await fetchOpenAIModelsRequest(apiKey, url);
325
+ if (res.ok) {
326
+ const json = safeParse(res.text);
327
+ const raw = Array.isArray(json?.data) ? json.data : [];
328
+ const models = raw
329
+ .map((m) => {
330
+ if (typeof m?.id !== 'string' || !m.id)
331
+ return null;
332
+ if (!isOpenAIChatModel(m.id))
333
+ return null;
334
+ return { slug: m.id, display_name: m.id, context_window: null, methods: ['chat.completions'] };
335
+ })
336
+ .filter((m) => m !== null);
337
+ return { ok: true, status: res.status, models };
338
+ }
339
+ last = { status: res.status, message: res.message };
340
+ if (res.status !== 404)
341
+ break;
342
+ }
343
+ return { ok: false, status: last?.status ?? 0, message: last?.message ?? 'OpenAI: onbekende fout.' };
344
+ }
345
+ async function verifyOpenAIChat(apiKey, baseUrl, models) {
346
+ const slugs = models.map(m => m.slug);
347
+ if (slugs.length === 0) {
348
+ return { ok: true, skipped: true, message: 'Modellen opgehaald, maar geen chat-model beschikbaar voor deze key — verificatie overgeslagen.' };
349
+ }
350
+ const chosen = OPENAI_PREFERRED_MODELS.find(p => slugs.includes(p))
351
+ ?? slugs.find(s => s.startsWith('gpt-4o-mini'))
352
+ ?? slugs.find(s => s.startsWith('gpt-'))
353
+ ?? slugs[0];
354
+ const url = openAIChatUrl(baseUrl);
355
+ const { signal, done } = withTimeout();
356
+ try {
357
+ const res = await fetch(url, {
358
+ method: 'POST',
359
+ headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json' },
360
+ body: JSON.stringify({
361
+ model: chosen,
362
+ messages: [{ role: 'user', content: 'ping' }],
363
+ max_completion_tokens: 1,
364
+ }),
365
+ signal,
366
+ });
367
+ const text = await res.text();
368
+ if (!res.ok) {
369
+ const parsed = parseOpenAIError(res.status, text, { endpoint: 'chat.completions', model: chosen, url });
370
+ return { ok: false, status: res.status, message: `chat/completions (${chosen}) → ${parsed}` };
371
+ }
372
+ return { ok: true };
373
+ }
374
+ catch (e) {
375
+ const msg = e instanceof Error && e.name === 'AbortError'
376
+ ? 'Timeout: OpenAI reageerde niet binnen 10s tijdens chat-check.'
377
+ : `Netwerkfout tijdens chat-check: ${e instanceof Error ? e.message : 'onbekend'}`;
378
+ return { ok: false, status: 0, message: msg };
379
+ }
380
+ finally {
381
+ done();
382
+ }
383
+ }
384
+ // ─── Anthropic ───────────────────────────────────────────────────────
385
+ const ANTHROPIC_DEFAULT_API_BASE = 'https://api.anthropic.com';
386
+ const ANTHROPIC_VERSION = '2023-06-01';
387
+ const ANTHROPIC_EXCLUDE_TOKENS = ['instant', 'embedding'];
388
+ const ANTHROPIC_LEGACY_PREFIXES = ['claude-1', 'claude-2'];
389
+ const ANTHROPIC_PREFERRED_MODELS = [
390
+ 'claude-haiku-4-5',
391
+ 'claude-3-5-haiku-latest',
392
+ 'claude-3-5-haiku-20241022',
393
+ 'claude-3-haiku-20240307',
394
+ 'claude-sonnet-4-5',
395
+ 'claude-3-5-sonnet-latest',
396
+ ];
397
+ function normalizeAnthropicApiBase(baseUrl) {
398
+ const raw = (baseUrl || ANTHROPIC_DEFAULT_API_BASE).trim().replace(/\/+$/, '');
399
+ if (!raw)
400
+ return ANTHROPIC_DEFAULT_API_BASE;
401
+ try {
402
+ const url = new URL(raw);
403
+ url.pathname = '/';
404
+ url.search = '';
405
+ url.hash = '';
406
+ return url.toString().replace(/\/+$/, '');
407
+ }
408
+ catch {
409
+ return ANTHROPIC_DEFAULT_API_BASE;
410
+ }
411
+ }
412
+ function anthropicContextDetails(ctx) {
413
+ return [
414
+ ctx.endpoint ? `endpoint: ${ctx.endpoint}` : null,
415
+ ctx.model ? `model: ${ctx.model}` : null,
416
+ ctx.url ? `url: ${ctx.url}` : null,
417
+ ].filter(Boolean).join(', ');
418
+ }
419
+ function parseAnthropicError(status, text, ctx = {}) {
420
+ const json = safeParse(text);
421
+ const msg = json?.error?.message;
422
+ const type = json?.error?.type;
423
+ const details = anthropicContextDetails(ctx);
424
+ if (msg) {
425
+ const base = type ? `${type}: ${msg}` : msg;
426
+ return details ? `${base} (${details})` : base;
427
+ }
428
+ if (status === 401)
429
+ return 'Anthropic weigert de API-key (401). Controleer of de key geldig is.';
430
+ if (status === 403)
431
+ return 'Anthropic weigert toegang (403). De key heeft geen rechten voor dit endpoint of workspace.';
432
+ if (status === 404)
433
+ return `Anthropic gaf 404 terug${details ? ` (${details})` : ''}. Controleer de base URL en of het model beschikbaar is voor deze key.`;
434
+ if (status === 429)
435
+ return 'Anthropic rate-limit of credits op (429). Wacht even of controleer je billing.';
436
+ if (status >= 500)
437
+ return `Anthropic upstream fout (${status}). Probeer het later opnieuw.`;
438
+ return `Onverwachte fout van Anthropic (${status}).`;
439
+ }
440
+ function isAnthropicChatModel(slug) {
441
+ const s = slug.toLowerCase();
442
+ if (!s.startsWith('claude-'))
443
+ return false;
444
+ if (ANTHROPIC_EXCLUDE_TOKENS.some(t => s.includes(t)))
445
+ return false;
446
+ if (ANTHROPIC_LEGACY_PREFIXES.some(p => s.startsWith(p)))
447
+ return false;
448
+ return true;
449
+ }
450
+ function anthropicModelsUrl(baseUrl, afterId) {
451
+ const qs = new URLSearchParams({ limit: '1000' });
452
+ if (afterId)
453
+ qs.set('after_id', afterId);
454
+ return `${normalizeAnthropicApiBase(baseUrl)}/v1/models?${qs.toString()}`;
455
+ }
456
+ function anthropicMessagesUrl(baseUrl) {
457
+ return `${normalizeAnthropicApiBase(baseUrl)}/v1/messages`;
458
+ }
459
+ async function fetchAnthropicModelsPage(apiKey, url) {
460
+ const { signal, done } = withTimeout();
461
+ try {
462
+ const res = await fetch(url, {
463
+ headers: { 'x-api-key': apiKey, 'anthropic-version': ANTHROPIC_VERSION },
464
+ signal,
465
+ });
466
+ const text = await res.text();
467
+ if (!res.ok)
468
+ return { ok: false, status: res.status, message: parseAnthropicError(res.status, text, { endpoint: 'models.list', url }) };
469
+ return { ok: true, status: res.status, text };
470
+ }
471
+ catch (e) {
472
+ const msg = e instanceof Error && e.name === 'AbortError'
473
+ ? 'Timeout: Anthropic reageerde niet binnen 10s.'
474
+ : `Netwerkfout: ${e instanceof Error ? e.message : 'onbekend'}`;
475
+ return { ok: false, status: 0, message: msg };
476
+ }
477
+ finally {
478
+ done();
479
+ }
480
+ }
481
+ async function fetchAnthropicModels(apiKey, baseUrl) {
482
+ const collected = [];
483
+ let afterId = undefined;
484
+ let lastStatus = 0;
485
+ for (let page = 0; page < 5; page++) {
486
+ const primaryUrl = anthropicModelsUrl(baseUrl, afterId);
487
+ const fallbackUrl = anthropicModelsUrl(ANTHROPIC_DEFAULT_API_BASE, afterId);
488
+ const urls = primaryUrl === fallbackUrl ? [primaryUrl] : [primaryUrl, fallbackUrl];
489
+ let pageRes = null;
490
+ for (const url of urls) {
491
+ const attempt = await fetchAnthropicModelsPage(apiKey, url);
492
+ pageRes = attempt;
493
+ if (attempt.ok)
494
+ break;
495
+ if (attempt.status !== 404)
496
+ break;
497
+ }
498
+ if (!pageRes)
499
+ return { ok: false, status: 0, message: 'Anthropic: onbekende fout.' };
500
+ if (!pageRes.ok)
501
+ return { ok: false, status: pageRes.status, message: pageRes.message };
502
+ lastStatus = pageRes.status;
503
+ const json = safeParse(pageRes.text);
504
+ const raw = Array.isArray(json?.data) ? json.data : [];
505
+ for (const m of raw) {
506
+ if (typeof m?.id !== 'string' || !m.id)
507
+ continue;
508
+ if (!isAnthropicChatModel(m.id))
509
+ continue;
510
+ collected.push({
511
+ slug: m.id,
512
+ display_name: typeof m.display_name === 'string' && m.display_name ? m.display_name : m.id,
513
+ context_window: null,
514
+ methods: ['messages'],
515
+ });
516
+ }
517
+ if (!json?.has_more || !json.last_id)
518
+ break;
519
+ afterId = json.last_id;
520
+ }
521
+ return { ok: true, status: lastStatus || 200, models: collected };
522
+ }
523
+ async function verifyAnthropicMessage(apiKey, baseUrl, models) {
524
+ const slugs = models.map(m => m.slug);
525
+ if (slugs.length === 0) {
526
+ return { ok: true, skipped: true, message: 'Modellen opgehaald, maar geen chat-model beschikbaar voor deze key — verificatie overgeslagen.' };
527
+ }
528
+ const chosen = ANTHROPIC_PREFERRED_MODELS.find(p => slugs.includes(p))
529
+ ?? slugs.find(s => s.includes('haiku'))
530
+ ?? slugs.find(s => s.startsWith('claude-'))
531
+ ?? slugs[0];
532
+ const url = anthropicMessagesUrl(baseUrl);
533
+ const { signal, done } = withTimeout();
534
+ try {
535
+ const res = await fetch(url, {
536
+ method: 'POST',
537
+ headers: {
538
+ 'x-api-key': apiKey,
539
+ 'anthropic-version': ANTHROPIC_VERSION,
540
+ 'Content-Type': 'application/json',
541
+ },
542
+ body: JSON.stringify({
543
+ model: chosen,
544
+ max_tokens: 1,
545
+ messages: [{ role: 'user', content: 'ping' }],
546
+ }),
547
+ signal,
548
+ });
549
+ const text = await res.text();
550
+ if (!res.ok) {
551
+ const parsed = parseAnthropicError(res.status, text, { endpoint: 'messages', model: chosen, url });
552
+ return { ok: false, status: res.status, message: `messages (${chosen}) → ${parsed}` };
553
+ }
554
+ return { ok: true };
555
+ }
556
+ catch (e) {
557
+ const msg = e instanceof Error && e.name === 'AbortError'
558
+ ? 'Timeout: Anthropic reageerde niet binnen 10s tijdens messages-check.'
559
+ : `Netwerkfout tijdens messages-check: ${e instanceof Error ? e.message : 'onbekend'}`;
560
+ return { ok: false, status: 0, message: msg };
561
+ }
562
+ finally {
563
+ done();
564
+ }
565
+ }
566
+ // ─── Dispatch ────────────────────────────────────────────────────────
567
+ async function fetchModelsFor(provider, apiKey) {
568
+ const slug = (provider.slug || '').toLowerCase();
569
+ if (slug.includes('gemini') || slug.includes('google')) {
570
+ const res = await fetchGeminiModels(apiKey, provider.base_url);
571
+ if (!res.ok)
572
+ return res;
573
+ const usable = await probeGeminiAvailability(apiKey, provider.base_url, res.models);
574
+ return { ...res, models: usable };
575
+ }
576
+ if (slug.includes('openai') || slug.includes('gpt'))
577
+ return fetchOpenAIModels(apiKey, provider.base_url);
578
+ if (slug.includes('anthropic') || slug.includes('claude'))
579
+ return fetchAnthropicModels(apiKey, provider.base_url);
580
+ return { ok: false, status: 400, message: `Provider "${provider.name}" wordt niet ondersteund.` };
581
+ }
582
+ async function loadCredentialAndProvider(supabase, credentialId) {
583
+ const { data: cred, error: credErr } = await supabase
584
+ .from('ai_credentials').select('id, api_key, active, provider_id').eq('id', credentialId).maybeSingle();
585
+ if (credErr || !cred)
586
+ return { error: { ok: false, status: 404, message: 'Credential niet gevonden.' } };
587
+ const { data: prov, error: provErr } = await supabase
588
+ .from('ai_providers').select('id, slug, name, base_url').eq('id', cred.provider_id).maybeSingle();
589
+ if (provErr || !prov)
590
+ return { error: { ok: false, status: 404, message: 'Provider niet gevonden.' } };
591
+ return { credential: cred, provider: prov };
592
+ }
593
+ // Autorisatie: elke ingelogde gebruiker mag AI-credentials testen/synchroniseren.
594
+ // Rol-check bewust verwijderd; authenticatie regelt requireSupabaseAuth.
595
+ // ─── Server functions ────────────────────────────────────────────────
596
+ export const testAiCredential = createServerFn({ method: 'POST' })
597
+ .middleware([requireSupabaseAuth])
598
+ .inputValidator((input) => {
599
+ const v = input;
600
+ if (!v || typeof v.credentialId !== 'string' || !v.credentialId)
601
+ throw new Error('credentialId is verplicht');
602
+ return { credentialId: v.credentialId };
603
+ })
604
+ .handler(async ({ data, context }) => {
605
+ const { supabase } = context;
606
+ const loaded = await loadCredentialAndProvider(supabase, data.credentialId);
607
+ if ('error' in loaded)
608
+ return loaded.error;
609
+ const res = await fetchModelsFor(loaded.provider, loaded.credential.api_key);
610
+ if (!res.ok)
611
+ return { ok: false, status: res.status, message: res.message };
612
+ const providerSlug = (loaded.provider.slug || '').toLowerCase();
613
+ const isGemini = providerSlug.includes('gemini') || providerSlug.includes('google');
614
+ const isOpenAI = providerSlug.includes('openai') || providerSlug.includes('gpt');
615
+ const isAnthropic = providerSlug.includes('anthropic') || providerSlug.includes('claude');
616
+ if (isGemini) {
617
+ const gen = await verifyGeminiGenerate(loaded.credential.api_key, loaded.provider.base_url, res.models);
618
+ if (!gen.ok)
619
+ return { ok: false, status: gen.status, message: gen.message, sampleModels: res.models.map(m => m.slug) };
620
+ const skippedMsg = 'skipped' in gen ? gen.message : `Verbinding werkt. Key kan modellen listen én tekst genereren. ${res.models.length} modellen beschikbaar.`;
621
+ return {
622
+ ok: true, status: res.status,
623
+ message: skippedMsg,
624
+ sampleModels: res.models.map(m => m.slug),
625
+ };
626
+ }
627
+ if (isOpenAI) {
628
+ const gen = await verifyOpenAIChat(loaded.credential.api_key, loaded.provider.base_url, res.models);
629
+ if (!gen.ok)
630
+ return { ok: false, status: gen.status, message: gen.message, sampleModels: res.models.map(m => m.slug) };
631
+ const skippedMsg = 'skipped' in gen ? gen.message : `Verbinding werkt. Key kan modellen listen én chat-completions genereren. ${res.models.length} chat-modellen beschikbaar.`;
632
+ return {
633
+ ok: true, status: res.status,
634
+ message: skippedMsg,
635
+ sampleModels: res.models.map(m => m.slug),
636
+ };
637
+ }
638
+ if (isAnthropic) {
639
+ const gen = await verifyAnthropicMessage(loaded.credential.api_key, loaded.provider.base_url, res.models);
640
+ if (!gen.ok)
641
+ return { ok: false, status: gen.status, message: gen.message, sampleModels: res.models.map(m => m.slug) };
642
+ const skippedMsg = 'skipped' in gen ? gen.message : `Verbinding werkt. Key kan modellen listen én berichten genereren. ${res.models.length} chat-modellen beschikbaar.`;
643
+ return {
644
+ ok: true, status: res.status,
645
+ message: skippedMsg,
646
+ sampleModels: res.models.map(m => m.slug),
647
+ };
648
+ }
649
+ return {
650
+ ok: true, status: res.status,
651
+ message: `Verbinding werkt. ${res.models.length} modellen beschikbaar.`,
652
+ sampleModels: res.models.map(m => m.slug),
653
+ };
654
+ });
655
+ export const syncAiCredentialModels = createServerFn({ method: 'POST' })
656
+ .middleware([requireSupabaseAuth])
657
+ .inputValidator((input) => {
658
+ const v = input;
659
+ if (!v || typeof v.credentialId !== 'string' || !v.credentialId)
660
+ throw new Error('credentialId is verplicht');
661
+ return { credentialId: v.credentialId };
662
+ })
663
+ .handler(async ({ data, context }) => {
664
+ const { supabase } = context;
665
+ const loaded = await loadCredentialAndProvider(supabase, data.credentialId);
666
+ if ('error' in loaded)
667
+ return { ...loaded.error, synced: 0 };
668
+ const res = await fetchModelsFor(loaded.provider, loaded.credential.api_key);
669
+ if (!res.ok)
670
+ return { ok: false, status: res.status, message: res.message, synced: 0 };
671
+ if (res.models.length === 0) {
672
+ return { ok: true, status: res.status, message: 'Provider gaf 0 modellen terug.', synced: 0, models: [] };
673
+ }
674
+ const rows = res.models.map(m => ({
675
+ provider_id: loaded.provider.id,
676
+ slug: m.slug,
677
+ display_name: m.display_name,
678
+ context_window: m.context_window,
679
+ modality: 'text',
680
+ active: true,
681
+ }));
682
+ const { error: upsertErr } = await supabase
683
+ .from('ai_models')
684
+ .upsert(rows, { onConflict: 'provider_id,slug', ignoreDuplicates: false });
685
+ if (upsertErr) {
686
+ return { ok: false, status: 500, message: `Opslaan mislukt: ${upsertErr.message}`, synced: 0 };
687
+ }
688
+ // Modellen die de provider niet meer teruggeeft (uitgefaseerd of niet-chat)
689
+ // worden op inactief gezet, zodat ze uit de keuzelijsten verdwijnen.
690
+ let deactivated = 0;
691
+ const keep = res.models.map(m => m.slug);
692
+ const { data: stale } = await supabase
693
+ .from('ai_models')
694
+ .select('id, slug')
695
+ .eq('provider_id', loaded.provider.id)
696
+ .eq('active', true);
697
+ const staleIds = (Array.isArray(stale) ? stale : [])
698
+ .filter((r) => !keep.includes(r.slug))
699
+ .map((r) => r.id);
700
+ if (staleIds.length > 0) {
701
+ const { error: deactErr } = await supabase
702
+ .from('ai_models').update({ active: false }).in('id', staleIds);
703
+ if (!deactErr)
704
+ deactivated = staleIds.length;
705
+ }
706
+ return {
707
+ ok: true, status: res.status,
708
+ message: deactivated > 0
709
+ ? `${rows.length} modellen gesynchroniseerd, ${deactivated} niet meer ondersteunde modellen uitgeschakeld.`
710
+ : `${rows.length} modellen gesynchroniseerd.`,
711
+ synced: rows.length,
712
+ models: rows.map(r => ({ slug: r.slug, display_name: r.display_name })),
713
+ };
714
+ });