@k2b/cloud 0.43.0 → 0.45.0

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 (308) hide show
  1. package/package.json +11 -2
  2. package/src/_internal/app-access-request.ts +13 -0
  3. package/src/_internal/app-access-resources.ts +141 -0
  4. package/src/_internal/app-event-catalog.ts +40 -0
  5. package/src/_internal/app-event-schemas.ts +127 -0
  6. package/src/_internal/application-package.ts +40 -0
  7. package/src/_internal/built-in-app-ids.json +27 -0
  8. package/src/_internal/capabilities.ts +158 -12
  9. package/src/_internal/capability-origin.ts +4 -0
  10. package/src/_internal/capability-streams.ts +7 -5
  11. package/src/_internal/define-app.ts +57 -6
  12. package/src/_internal/object-store-area.ts +9 -0
  13. package/src/_internal/object-store-config.ts +101 -0
  14. package/src/_internal/object-store-key.ts +14 -0
  15. package/src/_internal/process-identity.ts +7 -2
  16. package/src/_internal/registry-validation.ts +11 -1
  17. package/src/_internal/registry.ts +8 -1
  18. package/src/_internal/runtime-context.ts +8 -1
  19. package/src/access/AppAccessEditor.tsx +2 -0
  20. package/src/access/GroupCoverage.tsx +20 -5
  21. package/src/access/PermissionEditor.tsx +20 -5
  22. package/src/access/PrincipalAvatar.tsx +30 -21
  23. package/src/access/PrincipalPicker.tsx +14 -4
  24. package/src/access/ResourceApiKeys.tsx +3 -4
  25. package/src/access/app-access-messages.ts +6 -0
  26. package/src/access/messages.ts +4 -4
  27. package/src/account/EntitySearch.tsx +12 -0
  28. package/src/account/ProfilePictureDialog.tsx +699 -0
  29. package/src/account/profile-picture-messages.ts +269 -0
  30. package/src/account/ui.ts +7 -0
  31. package/src/ai/admin.ts +3 -0
  32. package/src/ai/app-frame.ts +12 -0
  33. package/src/ai/application-tasks.ts +101 -0
  34. package/src/ai/assistant-models.ts +126 -64
  35. package/src/ai/capabilities.ts +19 -4
  36. package/src/ai/chart-block.ts +63 -30
  37. package/src/ai/chat/chart-block.tsx +12 -3
  38. package/src/ai/chat/composer-adapter.ts +8 -7
  39. package/src/ai/chat/message-actions.tsx +1 -1
  40. package/src/ai/chat/message-utils.ts +2 -2
  41. package/src/ai/chat/presentation.tsx +22 -1
  42. package/src/ai/chat-quotas.ts +5 -4
  43. package/src/ai/chat-tasks.ts +2 -2
  44. package/src/ai/client/controller.ts +11 -10
  45. package/src/ai/client/projection.ts +3 -3
  46. package/src/ai/compaction.ts +3 -3
  47. package/src/ai/credentials.ts +13 -114
  48. package/src/ai/default-tool-contracts.ts +4 -3
  49. package/src/ai/dictation-runtime.ts +10 -6
  50. package/src/ai/enrich.ts +2 -2
  51. package/src/ai/executor.ts +40 -25
  52. package/src/ai/http.ts +17 -29
  53. package/src/ai/index.ts +12 -5
  54. package/src/ai/inference-calls.ts +5 -4
  55. package/src/ai/maintenance.ts +1 -1
  56. package/src/ai/memory-learning-runs.ts +5 -5
  57. package/src/ai/memory-learning.ts +3 -3
  58. package/src/ai/message-queue.ts +1 -2
  59. package/src/ai/migrate.ts +75 -42
  60. package/src/ai/model-access-migrate.ts +3 -17
  61. package/src/ai/model-access.ts +44 -85
  62. package/src/ai/model-card.tsx +134 -0
  63. package/src/ai/model-catalog.ts +359 -0
  64. package/src/ai/model-chooser-messages.ts +88 -0
  65. package/src/ai/model-chooser.tsx +577 -0
  66. package/src/ai/model-delete.ts +234 -0
  67. package/src/ai/model-fixture.ts +39 -0
  68. package/src/ai/model-icon.tsx +36 -0
  69. package/src/ai/model-overview.ts +53 -0
  70. package/src/ai/model-usage.ts +45 -0
  71. package/src/ai/openai-api-messages.ts +0 -4
  72. package/src/ai/openai-api.ts +26 -9
  73. package/src/ai/personal-agent.ts +1 -1
  74. package/src/ai/prefs.ts +12 -10
  75. package/src/ai/project-tool.ts +4 -4
  76. package/src/ai/projects-authorization.ts +53 -0
  77. package/src/ai/projects-routes.ts +58 -47
  78. package/src/ai/projects.ts +110 -88
  79. package/src/ai/protocol.ts +2 -2
  80. package/src/ai/provider-connections.ts +161 -0
  81. package/src/ai/provider-errors.ts +55 -6
  82. package/src/ai/provider-fetch.ts +4 -0
  83. package/src/ai/provider.ts +1 -1
  84. package/src/ai/quota-limits.ts +36 -0
  85. package/src/ai/quota-provider.ts +4 -2
  86. package/src/ai/quota-report.ts +7 -4
  87. package/src/ai/quotas.ts +19 -8
  88. package/src/ai/recent-resources.ts +9 -3
  89. package/src/ai/request-headers.ts +14 -87
  90. package/src/ai/resource-refs.ts +6 -2
  91. package/src/ai/routes.ts +47 -48
  92. package/src/ai/runtime.ts +50 -22
  93. package/src/ai/settings.ts +93 -580
  94. package/src/ai/skill-tool.ts +4 -4
  95. package/src/ai/skills-routes.ts +27 -26
  96. package/src/ai/skills.ts +120 -81
  97. package/src/ai/store.ts +45 -23
  98. package/src/ai/stream.ts +1 -1
  99. package/src/ai/structured-runs.ts +2 -2
  100. package/src/ai/structured.ts +21 -44
  101. package/src/ai/task-contracts.ts +25 -5
  102. package/src/ai/task-execution.ts +15 -6
  103. package/src/ai/task-model.ts +9 -0
  104. package/src/ai/transcription.ts +27 -22
  105. package/src/ai/types.ts +50 -62
  106. package/src/ai/ui.tsx +13 -0
  107. package/src/ai/usage.ts +8 -8
  108. package/src/ai/validate.ts +8 -7
  109. package/src/ai/view-block.ts +2 -2
  110. package/src/api/accounts-entities.ts +22 -3
  111. package/src/api/admin-ai-quotas.ts +34 -56
  112. package/src/api/admin-ai-skills.ts +4 -1
  113. package/src/api/admin-ai-usage.ts +1 -1
  114. package/src/api/admin-ai.ts +173 -0
  115. package/src/api/admin-core-settings.ts +68 -244
  116. package/src/api/app-approval.ts +3 -1
  117. package/src/api/app-event-delivery.ts +51 -0
  118. package/src/api/app-events.ts +123 -0
  119. package/src/api/app-resource-access.ts +48 -0
  120. package/src/api/auth/account-mismatch.ts +24 -0
  121. package/src/api/auth.ts +35 -5
  122. package/src/api/capabilities.ts +28 -4
  123. package/src/api/index.ts +7 -1
  124. package/src/api/mcp.ts +175 -2
  125. package/src/api/me.ts +6 -4
  126. package/src/api/pwa-phone.ts +75 -26
  127. package/src/api/pwa.ts +34 -8
  128. package/src/api/resource-links.ts +83 -6
  129. package/src/api/resource-resolve.ts +50 -0
  130. package/src/api/search/schemas.ts +3 -1
  131. package/src/api/search.ts +9 -7
  132. package/src/app-pwa/contracts.ts +4 -0
  133. package/src/app-pwa/core-query-error.ts +20 -0
  134. package/src/app-pwa/index.ts +1 -0
  135. package/src/app-pwa/service.ts +91 -0
  136. package/src/app-pwa/transport.ts +22 -0
  137. package/src/approvals/messages.ts +2 -2
  138. package/src/browser/cloud-id.ts +86 -0
  139. package/src/browser/resource-clipboard.ts +4 -0
  140. package/src/browser/resource-links.ts +52 -0
  141. package/src/browser/session.ts +39 -0
  142. package/src/browser/shared-text-history-validation.ts +151 -0
  143. package/src/browser/shared-text-history.ts +464 -0
  144. package/src/browser/shared-text-offline-authorized.ts +29 -0
  145. package/src/browser/shared-text-offline.ts +351 -0
  146. package/src/browser/shared-text-provider.ts +714 -0
  147. package/src/browser/shared-text-table-contract.ts +150 -0
  148. package/src/browser/shared-text.ts +19 -0
  149. package/src/capabilities/server-invoke.ts +2 -11
  150. package/src/capabilities/testing.ts +5 -2
  151. package/src/cli/access.ts +6 -5
  152. package/src/cli/admin/ai-models.ts +199 -0
  153. package/src/cli/admin/ai-quotas.ts +6 -102
  154. package/src/cli/admin/ai-usage.ts +4 -4
  155. package/src/cli/admin/app-credentials.ts +5 -3
  156. package/src/cli/admin/index.ts +5 -2
  157. package/src/cli/apps.ts +89 -26
  158. package/src/cli/capabilities.ts +22 -1
  159. package/src/cli/events.ts +320 -0
  160. package/src/config/env.ts +40 -0
  161. package/src/contracts/app-access.ts +26 -2
  162. package/src/contracts/app-details.ts +89 -0
  163. package/src/contracts/app-events.ts +51 -0
  164. package/src/contracts/app.ts +21 -5
  165. package/src/contracts/capabilities.ts +143 -3
  166. package/src/contracts/index.ts +2 -0
  167. package/src/contracts/notification-types.ts +36 -9
  168. package/src/contracts/pwa.ts +17 -1
  169. package/src/contracts/registry.ts +6 -0
  170. package/src/contracts/resource-clipboard.ts +35 -0
  171. package/src/contracts/resource-pages.ts +140 -0
  172. package/src/contracts/resource-resolution.ts +13 -0
  173. package/src/contracts/shared.ts +6 -1
  174. package/src/contracts/user-notifications.ts +2 -0
  175. package/src/events/app-event-delivery.ts +100 -0
  176. package/src/events/app-events.testing.ts +65 -0
  177. package/src/events/app-events.ts +134 -0
  178. package/src/events/core-user-created.ts +24 -0
  179. package/src/events/index.ts +9 -0
  180. package/src/events/testing.ts +2 -0
  181. package/src/index.ts +1 -1
  182. package/src/server/api/respond.ts +17 -4
  183. package/src/server/index.ts +2 -0
  184. package/src/server/middleware/app-access.ts +1 -1
  185. package/src/server/middleware/auth.ts +21 -7
  186. package/src/server/middleware/index.ts +1 -0
  187. package/src/server/middleware/invocation.ts +2 -0
  188. package/src/server/public-storage.ts +106 -0
  189. package/src/server/services/access.ts +31 -10
  190. package/src/server/services/index.ts +1 -0
  191. package/src/services/accounts/app.ts +36 -13
  192. package/src/services/accounts/entities.ts +43 -19
  193. package/src/services/accounts/group-sql.ts +23 -0
  194. package/src/services/accounts/groups.ts +19 -14
  195. package/src/services/accounts/identities.ts +70 -11
  196. package/src/services/accounts/identity-reconciliation.ts +3 -1
  197. package/src/services/accounts/local-groups.ts +69 -4
  198. package/src/services/accounts/users.ts +45 -14
  199. package/src/services/app-access.ts +25 -7
  200. package/src/services/app-details.ts +224 -0
  201. package/src/services/app-discovery.ts +2 -1
  202. package/src/services/app-event-shapes.ts +139 -0
  203. package/src/services/app-events/accept.ts +116 -0
  204. package/src/services/app-events/admin.ts +250 -0
  205. package/src/services/app-events/declarations.ts +187 -0
  206. package/src/services/app-events/deliver.ts +218 -0
  207. package/src/services/app-events.ts +92 -0
  208. package/src/services/config-conflict.ts +11 -0
  209. package/src/services/core-user-events.ts +1 -0
  210. package/src/services/freeipa-config.ts +9 -0
  211. package/src/services/geo/ledger.ts +28 -10
  212. package/src/services/geo/service.ts +43 -24
  213. package/src/services/identity/core-origin.ts +12 -0
  214. package/src/services/identity/invocation-actor.ts +20 -2
  215. package/src/services/identity/invocation-authority.ts +4 -0
  216. package/src/services/identity/invocation-token.ts +238 -56
  217. package/src/services/identity/issue-invocation.ts +6 -1
  218. package/src/services/image-metadata.ts +65 -1
  219. package/src/services/index.ts +13 -1
  220. package/src/services/ipa/sync.ts +6 -2
  221. package/src/services/ipa/users.ts +4 -1
  222. package/src/services/mandates/index.ts +2 -2
  223. package/src/services/mandates/policy.ts +20 -12
  224. package/src/services/notifications/browser.ts +4 -0
  225. package/src/services/notifications/catalog.ts +5 -2
  226. package/src/services/notifications/platform.ts +7 -2
  227. package/src/services/notifications/user.ts +18 -7
  228. package/src/services/oauth-tokens.ts +28 -2
  229. package/src/services/object-store.ts +217 -0
  230. package/src/services/providers/local/users.ts +5 -0
  231. package/src/services/pwa-devices.ts +147 -37
  232. package/src/services/session/index.ts +26 -3
  233. package/src/services/settings/core-settings.ts +71 -43
  234. package/src/services/settings/index.ts +8 -0
  235. package/src/services/settings/versions.ts +26 -0
  236. package/src/shared/ai-models.ts +274 -0
  237. package/src/shared/ai-quotas.ts +2 -2
  238. package/src/shared/ai-usage.ts +2 -2
  239. package/src/shared/email-html.ts +1 -0
  240. package/src/shared/index.ts +2 -0
  241. package/src/shared/markdown/extensions/katex.ts +8 -1
  242. package/src/shared/markdown/extensions/tables.ts +14 -1
  243. package/src/shared/markdown/index.ts +22 -11
  244. package/src/shared-text/anchors.ts +29 -0
  245. package/src/shared-text/awareness.ts +87 -0
  246. package/src/shared-text/changes.ts +27 -0
  247. package/src/shared-text/documents.ts +433 -65
  248. package/src/shared-text/events.ts +12 -2
  249. package/src/shared-text/index.ts +16 -1
  250. package/src/shared-text/log.ts +24 -5
  251. package/src/shared-text/snapshots.ts +69 -23
  252. package/src/shared-text/structural-history.ts +377 -0
  253. package/src/shared-text/table-actions.ts +432 -0
  254. package/src/shared-text/text-diff.ts +75 -0
  255. package/src/shared-text/types.ts +24 -5
  256. package/src/ssr/AdminArea.tsx +131 -0
  257. package/src/ssr/AdminLayout.tsx +9 -3
  258. package/src/ssr/AdminSidebar.tsx +7 -7
  259. package/src/ssr/AppAccessDenied.tsx +2 -0
  260. package/src/ssr/AppBadge.tsx +14 -0
  261. package/src/ssr/AppLaunchpadPanel.tsx +2 -2
  262. package/src/ssr/CloudShell.island.tsx +44 -7
  263. package/src/ssr/Layout.tsx +9 -7
  264. package/src/ssr/MobileProfileActions.tsx +2 -2
  265. package/src/ssr/ProfilePreferences.tsx +2 -1
  266. package/src/ssr/PwaLayout.tsx +16 -1
  267. package/src/ssr/PwaPartCount.island.tsx +23 -0
  268. package/src/ssr/SettingsSaveOutcome.tsx +58 -0
  269. package/src/ssr/SettingsSaveStatus.island.tsx +16 -0
  270. package/src/ssr/__snapshots__/Layout.unchanged.render.test.tsx.snap +3 -3
  271. package/src/ssr/admin-active-link.ts +16 -0
  272. package/src/ssr/admin-navigation.ts +26 -31
  273. package/src/ssr/app-badges.ts +31 -7
  274. package/src/ssr/app-launchpad.tsx +6 -1
  275. package/src/ssr/index.ts +8 -0
  276. package/src/ssr/page-trail-runtime.ts +3 -1
  277. package/src/ssr/platform-messages.ts +11 -1
  278. package/src/ssr/profile-actions.ts +3 -6
  279. package/src/ssr/pwa-messages.ts +2 -2
  280. package/src/ssr/rail-navigation.ts +5 -1
  281. package/src/ssr/settings-draft.ts +153 -0
  282. package/src/ssr/shell-data.tsx +3 -1
  283. package/src/styles/global.css +1 -0
  284. package/src/styles/profile-picture.css +270 -0
  285. package/src/styles/utilities-navigation.css +6 -0
  286. package/src/workflows/ai/policy.ts +49 -0
  287. package/src/workflows/ai/runtime.ts +131 -21
  288. package/src/workflows/ai/store.ts +283 -35
  289. package/src/workflows/ai/types.ts +8 -2
  290. package/src/workflows/ai-actions.ts +148 -120
  291. package/src/workflows/authoring.ts +31 -0
  292. package/src/workflows/contracts.ts +1 -0
  293. package/src/workflows/definition.ts +2 -0
  294. package/src/workflows/editor.ts +15 -0
  295. package/src/workflows/index.ts +2 -1
  296. package/src/workflows/language/compiler.ts +34 -3
  297. package/src/workflows/language/expressions.ts +3 -2
  298. package/src/workflows/runtime/executor.ts +134 -34
  299. package/src/workflows/runtime/index.ts +2 -0
  300. package/src/workflows/runtime/ports.ts +5 -1
  301. package/src/workflows/store/actions.ts +1 -1
  302. package/src/workflows/store/definitions.ts +5 -0
  303. package/src/workflows/store/runs.ts +5 -3
  304. package/src/workflows/store/worker.ts +7 -2
  305. package/src/ai/model-pricing.ts +0 -43
  306. package/src/ai/model-request-settings.ts +0 -98
  307. package/src/ai/settings-messages.ts +0 -129
  308. package/src/shared/ai-model-request-settings.ts +0 -21
@@ -1,627 +1,140 @@
1
- import { z } from "zod";
2
1
  import { coreSettings } from "../services";
3
- import { stripImageDataUrlMetadata } from "../services/image-metadata";
4
- import { AiModelPricingSchema } from "../shared/ai-costs";
5
2
  import {
6
- AI_REQUEST_HEADERS_PROVIDER_ERROR,
7
- AiExtraBodySchema,
8
- AiReasoningEffortSchema,
9
- AiRequestHeadersSchema,
10
- providerSupportsRequestHeaders,
11
- } from "../shared/ai-request-options";
12
- import { getAiCredential, listAiCredentialProfileIds } from "./credentials";
3
+ type AiModelRequest,
4
+ type AiResolvedModelRef,
5
+ aiModelClassFallbackChain,
6
+ providerRequiresCredential,
7
+ providerSupportsCredential,
8
+ } from "../shared/ai-models";
9
+ import { providerSupportsRequestHeaders } from "../shared/ai-request-options";
10
+ import { getAiCredential, listAiCredentialProviderIds } from "./credentials";
13
11
  import { AI_FIRECRAWL_API_KEY_SETTING_KEY } from "./firecrawl-tools";
12
+ import { aiModelCatalog, readAiModelProfiles } from "./model-catalog";
14
13
  import { createAiProvider } from "./provider";
15
14
  import { getAiRequestHeaders } from "./request-headers";
16
- import {
17
- AI_DATA_BOUNDARIES,
18
- AI_MODEL_CAPABILITIES,
19
- type AiDataBoundary,
20
- type AiModelCapability,
21
- type AiModelPolicy,
22
- type AiModelProfile,
23
- type AiProviderId,
24
- type AiPublicModelProfile,
25
- type AiResolvedModel,
26
- type AiSettingsError,
27
- type AiSettingsState,
28
- } from "./types";
29
-
30
- const PROVIDERS = ["openai", "openrouter", "anthropic", "mistral", "gemini", "ollama", "vllm", "openai-compatible"] as const;
31
- const LEGACY_DATA_BOUNDARIES = ["local", "internal"] as const;
32
- const DATA_BOUNDARY_INPUTS = [...AI_DATA_BOUNDARIES, ...LEGACY_DATA_BOUNDARIES] as const;
33
- const HttpUrlSchema = z
34
- .string()
35
- .trim()
36
- .url()
37
- .refine((value) => {
38
- const protocol = new URL(value).protocol;
39
- return protocol === "http:" || protocol === "https:";
40
- }, "Provider endpoints must use HTTP or HTTPS.");
41
-
42
- export const providerRequiresCredential = (provider: AiProviderId): boolean =>
43
- provider === "openai" || provider === "openrouter" || provider === "anthropic" || provider === "mistral" || provider === "gemini";
44
-
45
- export const providerSupportsCredential = (provider: AiProviderId): boolean =>
46
- providerRequiresCredential(provider) || provider === "vllm" || provider === "openai-compatible";
47
-
48
- const defaultDataBoundary = (provider: AiProviderId): AiDataBoundary =>
49
- provider === "ollama" || provider === "vllm" || provider === "openai-compatible" ? "private" : "hosted";
50
-
51
- const normalizeDataBoundary = (boundary: (typeof DATA_BOUNDARY_INPUTS)[number] | undefined, provider: AiProviderId): AiDataBoundary => {
52
- if (boundary === "hosted") return "hosted";
53
- if (boundary === "private" || boundary === "local" || boundary === "internal") return "private";
54
- return defaultDataBoundary(provider);
55
- };
56
-
57
- const isModelCapability = (value: string): value is AiModelCapability => AI_MODEL_CAPABILITIES.some((capability) => capability === value);
58
-
59
- // Keep in sync with the ai.max_tool_result_chars default in core-settings.ts.
60
- // This is an operator ceiling. The runtime derives the actual per-result
61
- // budget from the selected model's context window.
62
- const DEFAULT_MAX_TOOL_RESULT_CHARS = 2_000_000;
63
-
64
- const normalizeMaxToolResultChars = (value: unknown): number => {
65
- const numeric = Number(value);
66
- if (!Number.isFinite(numeric) || numeric <= 0) return DEFAULT_MAX_TOOL_RESULT_CHARS;
67
- return Math.floor(numeric);
68
- };
69
-
70
- const normalizeCapabilities = (values: string[] | undefined): AiModelCapability[] => {
71
- if (!values) return ["streaming"];
72
- const capabilities = values.filter(isModelCapability);
73
- return [...new Set(capabilities)];
74
- };
75
-
76
- const ModelProfileSchema = z
77
- .object({
78
- id: z
79
- .string()
80
- .trim()
81
- .min(1)
82
- .regex(/^[a-z0-9][a-z0-9._-]*$/),
83
- label: z.string().trim().min(1),
84
- provider: z.enum(PROVIDERS),
85
- model: z.string().trim().min(1),
86
- enabled: z.boolean().default(true),
87
- // Small logo shown in the admin card and the composer model picker. Hard cap:
88
- // it ships to every user via the public model list (64x64 webp ≈ a few KB).
89
- image: z
90
- .string()
91
- .trim()
92
- .regex(/^data:image\//)
93
- .max(28_000)
94
- .optional(),
95
- // Legacy/advanced metadata is tolerated but no longer used for model selection.
96
- tags: z.array(z.string()).optional(),
97
- capabilities: z.array(z.string()).optional(),
98
- dataBoundary: z.enum(DATA_BOUNDARY_INPUTS).optional(),
99
- // Legacy name accepted for stored profiles created before dataBoundary.
100
- dataPolicy: z.enum(DATA_BOUNDARY_INPUTS).optional(),
101
- baseURL: HttpUrlSchema.optional(),
102
- contextWindow: z.number().int().positive().optional(),
103
- temperature: z.number().min(0).max(2).optional(),
104
- maxOutputTokens: z.number().int().positive().optional(),
105
- reasoningEffort: AiReasoningEffortSchema,
106
- extraBody: AiExtraBodySchema.optional(),
107
- requestHeaders: AiRequestHeadersSchema.optional(),
108
- maxLoadedTools: z.number().int().optional(),
109
- maxToolRounds: z.number().int().optional(),
110
- pricing: AiModelPricingSchema.optional(),
111
- })
112
- .superRefine((profile, ctx) => {
113
- if (profile.requestHeaders !== undefined && !providerSupportsRequestHeaders(profile.provider))
114
- ctx.addIssue({ code: "custom", path: ["requestHeaders"], message: AI_REQUEST_HEADERS_PROVIDER_ERROR });
115
- if (profile.capabilities?.includes("transcription")) {
116
- for (const key of ["reasoningEffort", "extraBody", "requestHeaders"] as const)
117
- if (profile[key] !== undefined)
118
- ctx.addIssue({ code: "custom", path: [key], message: "Request settings are not supported on transcription profiles." });
119
- if (profile.pricing) ctx.addIssue({ code: "custom", path: ["pricing"], message: "Audio pricing is not supported." });
120
- if (profile.capabilities.some((capability) => capability !== "transcription")) {
121
- ctx.addIssue({ code: "custom", path: ["capabilities"], message: "Audio transcription cannot be combined with chat capabilities." });
122
- }
123
- if (profile.provider !== "openai" && profile.provider !== "openai-compatible") {
124
- ctx.addIssue({
125
- code: "custom",
126
- path: ["provider"],
127
- message: "Audio transcription requires OpenAI or an OpenAI-compatible endpoint.",
128
- });
129
- }
130
- }
131
- if (profile.provider === "openai-compatible" && !profile.baseURL) {
132
- ctx.addIssue({ code: "custom", path: ["baseURL"], message: "OpenAI-compatible profiles require a base URL." });
133
- }
134
- });
135
-
136
- const profileToPublic = (profile: AiModelProfile): AiPublicModelProfile => ({
137
- id: profile.id,
138
- label: profile.label,
139
- provider: profile.provider,
140
- model: profile.model,
141
- image: profile.image,
142
- capabilities: profile.capabilities,
143
- dataBoundary: profile.dataBoundary,
144
- contextWindow: profile.contextWindow,
145
- });
146
-
147
- const normalizeProfile = (raw: z.infer<typeof ModelProfileSchema>): AiModelProfile => {
148
- const { requestHeaders: _requestHeaders, capabilities, dataBoundary, dataPolicy: legacyDataPolicy, tags: _legacyTags, ...profile } = raw;
149
- return {
150
- ...profile,
151
- capabilities: normalizeCapabilities(capabilities),
152
- dataBoundary: normalizeDataBoundary(dataBoundary ?? legacyDataPolicy, raw.provider),
153
- };
154
- };
155
-
156
- /** Sanitize every submitted model logo, including unchanged images from stored profiles. */
157
- export const prepareAiModelProfileImages = (rawJson: string): string => {
158
- const raw: unknown = JSON.parse(rawJson);
159
- if (!Array.isArray(raw)) return rawJson;
160
- return JSON.stringify(
161
- raw.map((profile: unknown) => {
162
- if (typeof profile !== "object" || profile === null || !("image" in profile) || typeof profile.image !== "string") return profile;
163
- return { ...profile, image: stripImageDataUrlMetadata(profile.image) };
164
- }),
165
- );
166
- };
167
-
168
- export const parseAiModelProfiles = (rawJson: string): { profiles: AiModelProfile[]; error?: AiSettingsError } => {
169
- let raw: unknown;
170
- try {
171
- raw = rawJson.trim() ? JSON.parse(rawJson) : [];
172
- } catch (error) {
173
- return {
174
- profiles: [],
175
- error: {
176
- code: "invalid_model_profiles",
177
- message: "AI model profiles must be valid JSON.",
178
- fields: { "ai.model_profiles_json": error instanceof Error ? error.message : "Invalid JSON" },
179
- },
180
- };
181
- }
182
-
183
- const parsed = z.array(ModelProfileSchema).safeParse(raw);
184
- if (!parsed.success) {
185
- return {
186
- profiles: [],
187
- error: {
188
- code: "invalid_model_profiles",
189
- message: "AI model profiles do not match the expected shape.",
190
- fields: { "ai.model_profiles_json": z.prettifyError(parsed.error) },
191
- },
192
- };
193
- }
194
-
195
- const seen = new Set<string>();
196
- const duplicate = parsed.data.find((profile) => {
197
- if (seen.has(profile.id)) return true;
198
- seen.add(profile.id);
199
- return false;
200
- });
201
-
202
- if (duplicate) {
203
- return {
204
- profiles: [],
205
- error: {
206
- code: "invalid_model_profiles",
207
- message: `Duplicate AI model profile id "${duplicate.id}".`,
208
- fields: { "ai.model_profiles_json": `Duplicate model profile id "${duplicate.id}".` },
209
- },
210
- };
211
- }
212
-
213
- const profiles = parsed.data.map(normalizeProfile);
214
- return { profiles };
215
- };
216
-
217
- export type AiModelReferenceSetting =
218
- | "ai.default_model_id"
219
- | "ai.background_model_id"
220
- | "ai.workflow_model_id"
221
- | "ai.vision_model_id"
222
- | "ai.audio_model_id";
223
-
224
- /**
225
- * One reason why a prospective AI configuration cannot be saved. Codes and
226
- * profile ids are stable and locale-free; the admin route turns them into
227
- * request-localized messages.
228
- */
229
- export type AiSettingsIssue =
230
- | { code: "model_required"; setting: AiModelReferenceSetting }
231
- | {
232
- code: "model_profile_missing" | "model_profile_disabled" | "model_profile_incompatible";
233
- setting: AiModelReferenceSetting;
234
- profileId: string;
235
- }
236
- | { code: "provider_credential_missing"; setting: "ai.model_profiles_json"; profileId: string };
237
-
238
- export const validateAiSettingsConfiguration = (input: {
239
- enabled: boolean;
240
- defaultModelId: string;
241
- backgroundModelId: string;
242
- visionModelId?: string;
243
- audioModelId?: string;
244
- workflowModelId: string;
245
- profiles: readonly AiModelProfile[];
246
- credentialProfileIds: readonly string[];
247
- }): AiSettingsIssue[] => {
248
- if (!input.enabled) return [];
249
-
250
- const issues: AiSettingsIssue[] = [];
251
- const isChatModel = (profile: AiModelProfile) => !profile.capabilities.includes("transcription");
252
- const checkReference = (
253
- setting: AiModelReferenceSetting,
254
- profileId: string | undefined,
255
- fits: (profile: AiModelProfile) => boolean,
256
- required = false,
257
- ) => {
258
- if (!profileId) {
259
- if (required) issues.push({ code: "model_required", setting });
260
- return;
261
- }
262
- const profile = input.profiles.find((item) => item.id === profileId);
263
- if (!profile) issues.push({ code: "model_profile_missing", setting, profileId });
264
- else if (!profile.enabled) issues.push({ code: "model_profile_disabled", setting, profileId });
265
- else if (!fits(profile)) issues.push({ code: "model_profile_incompatible", setting, profileId });
266
- };
267
-
268
- checkReference("ai.default_model_id", input.defaultModelId, isChatModel, true);
269
- checkReference("ai.background_model_id", input.backgroundModelId, isChatModel);
270
- checkReference("ai.workflow_model_id", input.workflowModelId, isChatModel);
271
- checkReference("ai.vision_model_id", input.visionModelId, (profile) => profile.capabilities.includes("vision"));
272
- checkReference("ai.audio_model_id", input.audioModelId, (profile) => profile.capabilities.includes("transcription"));
273
-
274
- const configured = new Set(input.credentialProfileIds);
275
- for (const profile of input.profiles) {
276
- if (profile.enabled && providerRequiresCredential(profile.provider) && !configured.has(profile.id)) {
277
- issues.push({ code: "provider_credential_missing", setting: "ai.model_profiles_json", profileId: profile.id });
278
- }
279
- }
280
-
281
- return issues;
282
- };
283
-
284
- export const planAiProfileCredentials = (input: {
285
- currentProfiles: readonly AiModelProfile[];
286
- nextProfiles: readonly AiModelProfile[];
287
- existingCredentialProfileIds: readonly string[];
288
- submittedCredentialProfileIds: readonly string[];
289
- }): { keepCredentialProfileIds: string[]; unsupportedCredentialProfileId?: string } => {
290
- const nextProfilesById = new Map(input.nextProfiles.map((profile) => [profile.id, profile]));
291
- const unsupportedCredentialProfileId = input.submittedCredentialProfileIds.find((profileId) => {
292
- const profile = nextProfilesById.get(profileId);
293
- return !profile || !providerSupportsCredential(profile.provider);
294
- });
295
- if (unsupportedCredentialProfileId) return { keepCredentialProfileIds: [], unsupportedCredentialProfileId };
296
-
297
- const existingCredentials = new Set(input.existingCredentialProfileIds);
298
- const submittedCredentials = new Set(input.submittedCredentialProfileIds);
299
- const currentProfilesById = new Map(input.currentProfiles.map((profile) => [profile.id, profile]));
300
- const keepCredentialProfileIds = input.nextProfiles
301
- .filter((profile) => {
302
- if (!providerSupportsCredential(profile.provider)) return false;
303
- if (submittedCredentials.has(profile.id)) return true;
304
- const current = currentProfilesById.get(profile.id);
305
- return current?.provider === profile.provider && existingCredentials.has(profile.id);
306
- })
307
- .map((profile) => profile.id);
308
-
309
- return { keepCredentialProfileIds };
310
- };
311
-
312
- export const resolveAiSettingsStateFromRaw = async (input: {
313
- enabled: boolean;
314
- defaultModelId: string;
315
- visionModelId?: string;
316
- profilesJson: string;
317
- globalInstructions?: string;
318
- localClientInstructions?: string;
319
- compactionInstructions?: string;
320
- maxToolResultChars?: unknown;
321
- firecrawlApiKey?: string;
322
- credentialProfileIds?: readonly string[];
323
- readCredential?: (profileId: string) => Promise<string | null | undefined>;
324
- }): Promise<AiSettingsState> => {
325
- const parsed = parseAiModelProfiles(input.profilesJson ?? "[]");
326
- const baseState = {
327
- enabled: Boolean(input.enabled),
328
- defaultModelId: input.defaultModelId ?? "",
329
- visionModelId: input.visionModelId ?? "",
330
- globalInstructions: input.globalInstructions ?? "",
331
- localClientInstructions: input.localClientInstructions ?? "",
332
- compactionInstructions: input.compactionInstructions ?? "",
333
- maxToolResultChars: normalizeMaxToolResultChars(input.maxToolResultChars),
334
- firecrawlConfigured: Boolean(input.firecrawlApiKey?.trim()),
335
- };
336
- if (parsed.error) {
337
- return {
338
- ok: false,
339
- ...baseState,
340
- profiles: parsed.profiles,
341
- error: parsed.error,
342
- };
343
- }
344
-
345
- if (!input.enabled) {
346
- return {
347
- ok: true,
348
- ...baseState,
349
- enabled: false,
350
- profiles: parsed.profiles,
351
- };
352
- }
353
-
354
- const defaultProfile = parsed.profiles.find((profile) => profile.id === input.defaultModelId);
355
- if (!input.defaultModelId || !defaultProfile) {
356
- return {
357
- ok: false,
358
- ...baseState,
359
- enabled: true,
360
- profiles: parsed.profiles,
361
- error: {
362
- code: "missing_default_model",
363
- message: "AI is enabled but no valid default model profile is configured.",
364
- fields: { "ai.default_model_id": "Choose an enabled model profile id." },
365
- },
366
- };
367
- }
368
-
369
- if (!defaultProfile.enabled || defaultProfile.capabilities.includes("transcription")) {
370
- return {
371
- ok: false,
372
- ...baseState,
373
- enabled: true,
374
- profiles: parsed.profiles,
375
- error: {
376
- code: "default_model_disabled",
377
- message: `Default AI model "${input.defaultModelId}" must be an enabled text model.`,
378
- fields: { "ai.default_model_id": "Choose an enabled model profile id." },
379
- },
380
- };
381
- }
15
+ import type { AiDataBoundary, AiModelPolicy, AiModelProfile, AiResolvedModel, AiSettingsError, AiSettingsState } from "./types";
382
16
 
383
- if (providerRequiresCredential(defaultProfile.provider)) {
384
- let credentialConfigured = input.credentialProfileIds?.includes(defaultProfile.id) ?? false;
385
- if (!input.credentialProfileIds && input.readCredential) {
386
- try {
387
- credentialConfigured = Boolean((await input.readCredential(defaultProfile.id))?.trim());
388
- } catch {
389
- credentialConfigured = false;
390
- }
391
- }
392
- if (!credentialConfigured) {
393
- return {
394
- ok: false,
395
- ...baseState,
396
- enabled: true,
397
- profiles: parsed.profiles,
398
- error: {
399
- code: "missing_provider_credential",
400
- message: `Default AI model "${input.defaultModelId}" is missing provider credentials.`,
401
- fields: { "ai.model_profiles_json": "Enter the provider API key on the default model profile." },
402
- },
403
- };
404
- }
405
- }
406
-
407
- return {
408
- ok: true,
409
- ...baseState,
410
- enabled: true,
411
- profiles: parsed.profiles,
412
- };
17
+ export { providerRequiresCredential, providerSupportsCredential };
18
+ export const aiSettingsFailure = (code: AiSettingsError["code"], message: string): never => {
19
+ throw Object.assign(new Error(message), { aiError: { code, message } satisfies AiSettingsError });
413
20
  };
414
-
415
- const readAiSettingsSnapshot = async (): Promise<{ state: AiSettingsState; credentialProfileIds: string[] }> => {
21
+ export const readAiSettingsState = async (): Promise<AiSettingsState> => {
416
22
  const [
417
23
  enabled,
418
- defaultModelId,
419
- visionModelId,
420
- profilesJson,
421
24
  globalInstructions,
422
25
  localClientInstructions,
423
26
  compactionInstructions,
424
27
  maxToolResultChars,
425
28
  firecrawlApiKey,
29
+ profiles,
30
+ assignments,
426
31
  ] = await Promise.all([
427
32
  coreSettings.get<boolean>("ai.enabled"),
428
- coreSettings.get<string>("ai.default_model_id"),
429
- coreSettings.get<string>("ai.vision_model_id"),
430
- coreSettings.get<string>("ai.model_profiles_json"),
431
33
  coreSettings.get<string>("ai.global_instructions"),
432
34
  coreSettings.get<string>("ai.local_client_instructions"),
433
35
  coreSettings.get<string>("ai.compaction_instructions"),
434
36
  coreSettings.get<number>("ai.max_tool_result_chars"),
435
37
  coreSettings.get<string>(AI_FIRECRAWL_API_KEY_SETTING_KEY),
38
+ readAiModelProfiles(),
39
+ aiModelCatalog.assignments(),
436
40
  ]);
437
-
438
- const parsed = parseAiModelProfiles(profilesJson ?? "[]");
439
- const defaultProfile = parsed.profiles.find((profile) => profile.id === defaultModelId);
440
- const needsCredentials =
441
- Boolean(enabled) &&
442
- !parsed.error &&
443
- Boolean(defaultProfile?.enabled) &&
444
- parsed.profiles.some((profile) => profile.enabled && providerRequiresCredential(profile.provider));
445
- const credentialProfileIds = needsCredentials ? await listAiCredentialProfileIds() : [];
446
-
447
- const state = await resolveAiSettingsStateFromRaw({
41
+ return {
42
+ ok: true,
448
43
  enabled: Boolean(enabled),
449
- defaultModelId: defaultModelId ?? "",
450
- visionModelId: visionModelId ?? "",
451
- profilesJson: profilesJson ?? "[]",
44
+ assignments,
452
45
  globalInstructions: globalInstructions ?? "",
453
46
  localClientInstructions: localClientInstructions ?? "",
454
47
  compactionInstructions: compactionInstructions ?? "",
455
- maxToolResultChars,
456
- firecrawlApiKey: firecrawlApiKey ?? "",
457
- credentialProfileIds,
458
- });
459
- return { state, credentialProfileIds };
460
- };
461
-
462
- export const readAiSettingsState = async (): Promise<AiSettingsState> => (await readAiSettingsSnapshot()).state;
463
-
464
- const hasAll = <T extends string>(values: readonly T[], required: readonly T[] | undefined): boolean =>
465
- !required || required.every((requiredValue) => values.includes(requiredValue));
466
-
467
- const matchesPolicy = (profile: AiModelProfile, policy: AiModelPolicy): boolean => {
468
- if (!profile.enabled) return false;
469
- if (profile.capabilities.includes("transcription") !== Boolean(policy.requiredCapabilities?.includes("transcription"))) return false;
470
- if ("allowedModelIds" in policy && policy.allowedModelIds && !policy.allowedModelIds.includes(profile.id)) return false;
471
- if (policy.allowedDataBoundaries && !policy.allowedDataBoundaries.includes(profile.dataBoundary)) return false;
472
- return hasAll(profile.capabilities, policy.requiredCapabilities);
473
- };
474
-
475
- const resolvePolicyModelId = (state: Extract<AiSettingsState, { ok: true }>, policy: AiModelPolicy, requestedModelId?: string): string => {
476
- if (policy.kind === "locked") return policy.modelId;
477
- if (policy.kind === "selectable") return requestedModelId ?? policy.defaultModelId ?? state.defaultModelId;
478
- return state.defaultModelId;
48
+ maxToolResultChars: maxToolResultChars > 0 ? maxToolResultChars : 2_000_000,
49
+ firecrawlConfigured: Boolean(firecrawlApiKey?.trim()),
50
+ profiles,
51
+ };
479
52
  };
480
-
481
- export const selectAiModelProfile = (
482
- state: Extract<AiSettingsState, { ok: true }>,
483
- policy: AiModelPolicy,
484
- requestedModelId?: string,
485
- ): AiModelProfile => {
486
- const modelId = resolvePolicyModelId(state, policy, requestedModelId);
487
- const profile = state.profiles.find((candidate) => candidate.id === modelId);
488
- if (!profile || !matchesPolicy(profile, policy)) {
489
- throw Object.assign(
490
- new Error(
491
- `AI model "${modelId}" is unavailable or not allowed for this chat or data boundary. An administrator can enable a compatible model permitted by the application policy in the AI settings.`,
492
- ),
493
- {
494
- aiError: {
495
- code: "model_policy_mismatch",
496
- message: `AI model "${modelId}" is unavailable or not allowed for this chat or data boundary. An administrator can enable a compatible model permitted by the application policy in the AI settings.`,
497
- fields: { modelProfileId: "Choose an allowed enabled model profile." },
498
- } satisfies AiSettingsError,
499
- },
500
- );
53
+ export const matchesAiModelPolicy = (profile: AiModelProfile, policy: AiModelPolicy): boolean =>
54
+ profile.enabled &&
55
+ profile.capabilities.includes("transcription") === Boolean(policy.requiredCapabilities?.includes("transcription")) &&
56
+ (!("allowedModelIds" in policy) || !policy.allowedModelIds || policy.allowedModelIds.includes(profile.id)) &&
57
+ (!policy.allowedDataBoundaries || policy.allowedDataBoundaries.includes(profile.dataBoundary)) &&
58
+ (!policy.requiredCapabilities || policy.requiredCapabilities.every((capability) => profile.capabilities.includes(capability)));
59
+
60
+ export const resolveAiModelRef = (
61
+ state: Pick<AiSettingsState, "enabled" | "assignments">,
62
+ request: AiModelRequest = { class: "balanced" },
63
+ ): AiResolvedModelRef => {
64
+ if (!state.enabled) return aiSettingsFailure("ai_disabled", "AI is disabled.");
65
+ if ("id" in request) return { id: request.id };
66
+ for (const modelClass of [request.class, ...aiModelClassFallbackChain(request.class)]) {
67
+ const assignment = state.assignments.find((assignment) => assignment.slot === modelClass);
68
+ if (assignment?.variantId) return { requestedClass: request.class, class: modelClass, id: assignment.variantId };
501
69
  }
70
+ return aiSettingsFailure("ai_no_model", "No AI model class is assigned.");
71
+ };
72
+ const policyRequest = (policy: AiModelPolicy, request?: AiModelRequest): AiModelRequest | undefined =>
73
+ policy.kind === "locked" ? { id: policy.modelId } : request;
74
+ export const selectAiModelProfile = (state: AiSettingsState, policy: AiModelPolicy, request?: AiModelRequest): AiModelProfile => {
75
+ const ref = resolveAiModelRef(state, policyRequest(policy, request));
76
+ const profile = state.profiles.find((candidate) => candidate.id === ref.id);
77
+ if (!profile || !matchesAiModelPolicy(profile, policy))
78
+ return aiSettingsFailure("model_policy_mismatch", "The AI model is missing, disabled, or outside the permitted policy.");
502
79
  return profile;
503
80
  };
504
-
505
- export const resolveAiModel = async (
81
+ export const resolveAiModelFromState = async (
82
+ state: AiSettingsState,
506
83
  policy: AiModelPolicy = { kind: "platform-default" },
507
- requestedModelId?: string,
84
+ request?: AiModelRequest,
508
85
  ): Promise<AiResolvedModel> => {
509
- const state = await readAiSettingsState();
510
- return resolveAiModelFromState(state, policy, requestedModelId);
86
+ const model = resolveAiModelRef(state, policyRequest(policy, request));
87
+ const profile = selectAiModelProfile(state, policy, request);
88
+ const credential = providerSupportsCredential(profile.provider) ? await getAiCredential(profile.providerId) : null;
89
+ if (providerRequiresCredential(profile.provider) && !credential?.trim())
90
+ return aiSettingsFailure("model_policy_mismatch", "The AI provider has no usable credential.");
91
+ const headers = providerSupportsRequestHeaders(profile.provider) ? await getAiRequestHeaders(profile.providerId) : {};
92
+ return { model, profile, provider: createAiProvider(profile, credential?.trim() || undefined, headers) };
511
93
  };
512
-
513
- const visionModelPolicy = (modelId: string, allowedDataBoundaries?: AiDataBoundary[]): AiModelPolicy => ({
514
- kind: "locked",
515
- modelId,
516
- requiredCapabilities: ["vision"],
517
- allowedDataBoundaries,
518
- });
519
-
94
+ export const resolveAiModel = async (
95
+ policy: AiModelPolicy = { kind: "platform-default" },
96
+ request?: AiModelRequest,
97
+ ): Promise<AiResolvedModel> => resolveAiModelFromState(await readAiSettingsState(), policy, request);
520
98
  export const resolveAiVisionModel = async (allowedDataBoundaries?: AiDataBoundary[]): Promise<AiResolvedModel> => {
521
99
  const state = await readAiSettingsState();
522
- if (!state.ok) throw Object.assign(new Error(state.error.message), { aiError: state.error });
523
- if (!state.enabled) {
524
- throw Object.assign(new Error("AI is disabled. An administrator can enable AI in the AI settings."), {
525
- aiError: {
526
- code: "ai_disabled",
527
- message: "AI is disabled. An administrator can enable AI in the AI settings.",
528
- } satisfies AiSettingsError,
529
- });
530
- }
531
- const modelId = (state.visionModelId ?? "").trim();
532
- if (!modelId)
533
- throw new Error(
534
- "Image inspection is not set up: no vision model is configured. An administrator can configure a vision model in the AI settings.",
535
- );
536
- return resolveAiModelFromState(state, visionModelPolicy(modelId, allowedDataBoundaries));
537
- };
538
-
539
- export const isAiVisionModelConfigured = async (allowedDataBoundaries?: AiDataBoundary[]): Promise<boolean> => {
540
- const { state, credentialProfileIds } = await readAiSettingsSnapshot();
541
- if (!state.ok || !state.enabled || !state.visionModelId?.trim()) return false;
100
+ if (!state.enabled) return aiSettingsFailure("ai_disabled", "AI is disabled.");
101
+ const id = state.assignments.find((assignment) => assignment.slot === "vision")?.variantId;
102
+ if (!id) return aiSettingsFailure("model_policy_mismatch", "No image inspection model is assigned.");
103
+ return resolveAiModelFromState(state, { kind: "locked", modelId: id, requiredCapabilities: ["vision"], allowedDataBoundaries });
104
+ };
105
+ export const isAiVisionModelConfigured = async (
106
+ allowedDataBoundaries?: AiDataBoundary[],
107
+ providedState?: AiSettingsState,
108
+ ): Promise<boolean> => {
542
109
  try {
543
- const profile = selectAiModelProfile(state, visionModelPolicy(state.visionModelId, allowedDataBoundaries));
544
- return isUsableProfile(profile, new Set(credentialProfileIds));
110
+ const state = providedState ?? (await readAiSettingsState());
111
+ const id = state.assignments.find((assignment) => assignment.slot === "vision")?.variantId;
112
+ if (!id) return false;
113
+ const profile = selectAiModelProfile(state, { kind: "locked", modelId: id, requiredCapabilities: ["vision"], allowedDataBoundaries });
114
+ return !providerRequiresCredential(profile.provider) || (await listAiCredentialProviderIds()).includes(profile.providerId);
545
115
  } catch {
546
116
  return false;
547
117
  }
548
118
  };
549
-
550
- export const resolveAiModelFromState = async (
551
- state: AiSettingsState,
552
- policy: AiModelPolicy = { kind: "platform-default" },
553
- requestedModelId?: string,
554
- ): Promise<AiResolvedModel> => {
555
- if (!state.ok) throw Object.assign(new Error(state.error.message), { aiError: state.error });
556
- if (!state.enabled) {
557
- throw Object.assign(new Error("AI is disabled. An administrator can enable AI in the AI settings."), {
558
- aiError: {
559
- code: "ai_disabled",
560
- message: "AI is disabled. An administrator can enable AI in the AI settings.",
561
- } satisfies AiSettingsError,
562
- });
563
- }
564
-
565
- const profile = selectAiModelProfile(state, policy, requestedModelId);
566
-
567
- const credential = providerSupportsCredential(profile.provider) ? await getAiCredential(profile.id) : null;
568
- if (providerRequiresCredential(profile.provider) && !credential?.trim()) {
569
- throw Object.assign(
570
- new Error(
571
- `AI model "${profile.id}" is missing provider credentials. An administrator can add the provider API key in the AI settings.`,
572
- ),
573
- {
574
- aiError: {
575
- code: "missing_provider_credential",
576
- message: `AI model "${profile.id}" is missing provider credentials. An administrator can add the provider API key in the AI settings.`,
577
- fields: { "ai.model_profiles_json": "Enter the provider API key on this model profile." },
578
- } satisfies AiSettingsError,
579
- },
580
- );
581
- }
582
-
583
- const headers = providerSupportsRequestHeaders(profile.provider) ? await getAiRequestHeaders(profile.id) : {};
584
- return { profile, provider: createAiProvider(profile, credential?.trim() || undefined, headers) };
585
- };
586
-
587
- const isUsableProfile = (profile: AiModelProfile, credentialProfileIds: ReadonlySet<string>): boolean =>
588
- !providerRequiresCredential(profile.provider) || credentialProfileIds.has(profile.id);
589
-
590
- export const listAiModels = async (policy: AiModelPolicy = { kind: "selectable" }): Promise<AiPublicModelProfile[]> => {
591
- const { state, credentialProfileIds } = await readAiSettingsSnapshot();
592
- if (!state.ok || !state.enabled) return [];
593
- const configured = new Set(credentialProfileIds);
594
- return state.profiles.filter((profile) => matchesPolicy(profile, policy) && isUsableProfile(profile, configured)).map(profileToPublic);
119
+ export const listAiModels = async (
120
+ policy: AiModelPolicy = { kind: "selectable" },
121
+ providedState?: AiSettingsState,
122
+ ): Promise<AiModelProfile[]> => {
123
+ const [state, credentials] = await Promise.all([providedState ?? readAiSettingsState(), listAiCredentialProviderIds()]);
124
+ if (!state.enabled) return [];
125
+ return state.profiles.filter(
126
+ (profile) =>
127
+ matchesAiModelPolicy(profile, policy) && (!providerRequiresCredential(profile.provider) || credentials.includes(profile.providerId)),
128
+ );
595
129
  };
596
-
597
130
  export const toPublicAiSettingsState = async (allowedDataBoundaries?: AiDataBoundary[]) => {
598
- const { state, credentialProfileIds } = await readAiSettingsSnapshot();
599
- const configured = new Set(credentialProfileIds);
600
- let visionModelConfigured = false;
601
- if (state.ok && state.enabled && state.visionModelId?.trim()) {
602
- try {
603
- const profile = selectAiModelProfile(state, visionModelPolicy(state.visionModelId, allowedDataBoundaries));
604
- visionModelConfigured = isUsableProfile(profile, configured);
605
- } catch {
606
- // The configured auxiliary model is outside this application's policy.
607
- }
608
- }
131
+ const state = await readAiSettingsState();
609
132
  return {
610
- ok: state.ok,
133
+ ok: true,
611
134
  enabled: state.enabled,
612
- defaultModelId: state.defaultModelId,
613
- visionModelConfigured,
614
- error: state.ok ? null : state.error,
135
+ visionModelConfigured: await isAiVisionModelConfigured(allowedDataBoundaries),
615
136
  firecrawlConfigured: state.firecrawlConfigured,
616
- models:
617
- state.ok && state.enabled
618
- ? state.profiles
619
- .filter(
620
- (profile) => matchesPolicy(profile, { kind: "selectable", allowedDataBoundaries }) && isUsableProfile(profile, configured),
621
- )
622
- .map(profileToPublic)
623
- : [],
137
+ error: null,
624
138
  };
625
139
  };
626
-
627
- export type { AiDataBoundary, AiModelCapability, AiModelPolicy, AiModelProfile };
140
+ export type { AiDataBoundary, AiModelCapability, AiModelPolicy, AiModelProfile } from "./types";