@k2b/cloud 0.42.0 → 0.44.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 (352) hide show
  1. package/package.json +13 -5
  2. package/scripts/app-favicon.ts +7 -6
  3. package/scripts/build.ts +3 -0
  4. package/scripts/installable-assets.ts +61 -0
  5. package/scripts/preload.ts +3 -0
  6. package/src/_internal/app-access-path.ts +12 -0
  7. package/src/_internal/app-access-request.ts +13 -0
  8. package/src/_internal/app-access-resources.ts +141 -0
  9. package/src/_internal/app-event-catalog.ts +40 -0
  10. package/src/_internal/app-event-schemas.ts +127 -0
  11. package/src/_internal/application-package.ts +40 -0
  12. package/src/_internal/built-in-app-ids.json +27 -0
  13. package/src/_internal/capabilities.ts +185 -12
  14. package/src/_internal/capability-origin.ts +4 -0
  15. package/src/_internal/capability-streams.ts +10 -5
  16. package/src/_internal/define-app.ts +159 -45
  17. package/src/_internal/installable-assets.ts +90 -0
  18. package/src/_internal/installable-head.ts +22 -0
  19. package/src/_internal/object-store-area.ts +9 -0
  20. package/src/_internal/object-store-config.ts +101 -0
  21. package/src/_internal/object-store-key.ts +14 -0
  22. package/src/_internal/page-responses.ts +34 -3
  23. package/src/_internal/process-identity.ts +7 -2
  24. package/src/_internal/registry-validation.ts +15 -1
  25. package/src/_internal/registry.ts +44 -1
  26. package/src/_internal/runtime-context.ts +9 -1
  27. package/src/_internal/runtime-watcher.ts +8 -0
  28. package/src/_internal/status-preserving-ssr.ts +12 -1
  29. package/src/access/AppAccessEditor.browser-fixture.ts +429 -0
  30. package/src/access/AppAccessEditor.browser-frame.tsx +68 -0
  31. package/src/access/AppAccessEditor.browser-harness.tsx +65 -0
  32. package/src/access/AppAccessEditor.tsx +380 -0
  33. package/src/access/PermissionEditor.browser-harness.tsx +22 -2
  34. package/src/access/PermissionEditor.tsx +386 -77
  35. package/src/access/PrincipalAvatar.tsx +63 -0
  36. package/src/access/PrincipalPicker.tsx +29 -8
  37. package/src/access/ResourceApiKeys.tsx +3 -4
  38. package/src/access/app-access-adapter.ts +51 -0
  39. package/src/access/app-access-messages.ts +172 -0
  40. package/src/access/messages.ts +14 -0
  41. package/src/access/ui.ts +4 -1
  42. package/src/account/Avatar.tsx +21 -3
  43. package/src/account/ProfilePictureDialog.tsx +699 -0
  44. package/src/account/profile-picture-messages.ts +269 -0
  45. package/src/account/ui.ts +8 -1
  46. package/src/ai/admin.ts +3 -0
  47. package/src/ai/app-frame.ts +12 -0
  48. package/src/ai/application-tasks.ts +101 -0
  49. package/src/ai/assistant-models.ts +126 -64
  50. package/src/ai/capabilities.ts +19 -4
  51. package/src/ai/capability-execution.ts +5 -2
  52. package/src/ai/chart-block.ts +63 -30
  53. package/src/ai/chat/chart-block.tsx +12 -3
  54. package/src/ai/chat/composer-adapter.ts +8 -7
  55. package/src/ai/chat/message-actions.tsx +1 -1
  56. package/src/ai/chat/message-utils.ts +2 -2
  57. package/src/ai/chat/presentation.tsx +22 -1
  58. package/src/ai/chat-quotas.ts +5 -4
  59. package/src/ai/chat-tasks.ts +2 -2
  60. package/src/ai/client/controller.ts +11 -10
  61. package/src/ai/client/projection.ts +3 -3
  62. package/src/ai/code-capability-routes.ts +1 -0
  63. package/src/ai/compaction.ts +3 -3
  64. package/src/ai/credentials.ts +13 -114
  65. package/src/ai/default-tool-contracts.ts +4 -3
  66. package/src/ai/dictation-runtime.ts +4 -4
  67. package/src/ai/enrich.ts +2 -2
  68. package/src/ai/executor.ts +38 -27
  69. package/src/ai/http.ts +17 -29
  70. package/src/ai/index.ts +11 -5
  71. package/src/ai/inference-calls.ts +6 -4
  72. package/src/ai/maintenance.ts +1 -1
  73. package/src/ai/memory-learning-runs.ts +5 -5
  74. package/src/ai/memory-learning.ts +3 -3
  75. package/src/ai/message-queue.ts +1 -2
  76. package/src/ai/migrate.ts +75 -42
  77. package/src/ai/model-access-migrate.ts +3 -17
  78. package/src/ai/model-access.ts +44 -85
  79. package/src/ai/model-card.tsx +134 -0
  80. package/src/ai/model-catalog.ts +359 -0
  81. package/src/ai/model-chooser-messages.ts +88 -0
  82. package/src/ai/model-chooser.tsx +577 -0
  83. package/src/ai/model-delete.ts +234 -0
  84. package/src/ai/model-fixture.ts +39 -0
  85. package/src/ai/model-icon.tsx +36 -0
  86. package/src/ai/model-overview.ts +53 -0
  87. package/src/ai/model-usage.ts +45 -0
  88. package/src/ai/openai-api-messages.ts +0 -4
  89. package/src/ai/openai-api-slots.ts +10 -2
  90. package/src/ai/openai-api.ts +83 -16
  91. package/src/ai/personal-agent.ts +1 -1
  92. package/src/ai/prefs.ts +12 -10
  93. package/src/ai/projects-routes.ts +2 -1
  94. package/src/ai/projects.ts +13 -12
  95. package/src/ai/protocol.ts +2 -2
  96. package/src/ai/provider-connections.ts +161 -0
  97. package/src/ai/provider-errors.ts +138 -0
  98. package/src/ai/provider-fetch.ts +4 -0
  99. package/src/ai/provider-retry.ts +1 -1
  100. package/src/ai/provider.ts +2 -1
  101. package/src/ai/quota-limits.ts +36 -0
  102. package/src/ai/quota-provider.ts +11 -2
  103. package/src/ai/quota-report.ts +7 -4
  104. package/src/ai/quotas-migrate.ts +1 -0
  105. package/src/ai/quotas.ts +19 -8
  106. package/src/ai/recent-resources.ts +6 -0
  107. package/src/ai/request-headers.ts +14 -87
  108. package/src/ai/resource-refs.ts +6 -2
  109. package/src/ai/routes.ts +17 -18
  110. package/src/ai/runtime.ts +50 -22
  111. package/src/ai/settings.ts +93 -580
  112. package/src/ai/store.ts +45 -23
  113. package/src/ai/stream.ts +1 -1
  114. package/src/ai/structured-runs.ts +2 -2
  115. package/src/ai/structured.ts +29 -46
  116. package/src/ai/task-contracts.ts +25 -5
  117. package/src/ai/task-execution.ts +15 -6
  118. package/src/ai/task-model.ts +9 -0
  119. package/src/ai/transcription.ts +37 -21
  120. package/src/ai/turn-failure.ts +24 -2
  121. package/src/ai/types.ts +50 -62
  122. package/src/ai/ui.tsx +13 -0
  123. package/src/ai/usage.ts +8 -8
  124. package/src/ai/validate.ts +8 -7
  125. package/src/ai/view-block.ts +2 -2
  126. package/src/api/accounts-entities.ts +40 -16
  127. package/src/api/admin-ai-quotas.ts +34 -56
  128. package/src/api/admin-ai-usage.ts +1 -1
  129. package/src/api/admin-ai.ts +173 -0
  130. package/src/api/admin-core-settings.ts +68 -244
  131. package/src/api/admin-directory-shares.ts +55 -0
  132. package/src/api/admin-identity.ts +81 -2
  133. package/src/api/app-access.ts +85 -0
  134. package/src/api/app-approval.ts +3 -1
  135. package/src/api/app-event-delivery.ts +51 -0
  136. package/src/api/app-events.ts +123 -0
  137. package/src/api/app-resource-access.ts +48 -0
  138. package/src/api/auth/account-mismatch.ts +24 -0
  139. package/src/api/auth.ts +26 -3
  140. package/src/api/capabilities.ts +127 -15
  141. package/src/api/capability-streams.ts +2 -5
  142. package/src/api/identity-child-invocation.ts +145 -0
  143. package/src/api/index.ts +11 -2
  144. package/src/api/mcp.ts +180 -5
  145. package/src/api/me.ts +38 -17
  146. package/src/api/pwa-phone.ts +87 -48
  147. package/src/api/pwa.ts +34 -8
  148. package/src/api/resource-links.ts +83 -6
  149. package/src/api/resource-resolve.ts +50 -0
  150. package/src/api/search/schemas.ts +3 -1
  151. package/src/api/search.ts +9 -7
  152. package/src/api/widgets.ts +1 -0
  153. package/src/app-pwa/authority.ts +75 -0
  154. package/src/app-pwa/contracts.ts +177 -0
  155. package/src/app-pwa/core-query-error.ts +20 -0
  156. package/src/app-pwa/index.ts +23 -0
  157. package/src/app-pwa/protocol.ts +27 -0
  158. package/src/app-pwa/schema.ts +59 -0
  159. package/src/app-pwa/service.ts +861 -0
  160. package/src/app-pwa/transaction.ts +338 -0
  161. package/src/app-pwa/transport-input.ts +57 -0
  162. package/src/app-pwa/transport.ts +235 -0
  163. package/src/approvals/messages.ts +2 -2
  164. package/src/browser/app-access-recovery.ts +44 -0
  165. package/src/browser/app-session.ts +14 -3
  166. package/src/browser/cloud-id.ts +86 -0
  167. package/src/browser/live-websocket.ts +5 -0
  168. package/src/browser/pwa-drafts.ts +57 -0
  169. package/src/browser/resource-clipboard.ts +4 -0
  170. package/src/browser/resource-links.ts +52 -0
  171. package/src/browser/resource-picker.tsx +3 -2
  172. package/src/capabilities/calendar-sources.ts +302 -0
  173. package/src/capabilities/executions.ts +10 -3
  174. package/src/capabilities/migrate.ts +4 -0
  175. package/src/capabilities/nested.ts +43 -0
  176. package/src/capabilities/server-invoke.ts +202 -0
  177. package/src/capabilities/server.ts +26 -187
  178. package/src/capabilities/testing.ts +5 -2
  179. package/src/cli/access.ts +6 -5
  180. package/src/cli/account.ts +57 -0
  181. package/src/cli/admin/agents.ts +66 -0
  182. package/src/cli/admin/ai-models.ts +199 -0
  183. package/src/cli/admin/ai-quotas.ts +6 -102
  184. package/src/cli/admin/ai-usage.ts +4 -4
  185. package/src/cli/admin/app-credentials.ts +5 -3
  186. package/src/cli/admin/index.ts +7 -3
  187. package/src/cli/app-access.ts +60 -0
  188. package/src/cli/apps.ts +89 -26
  189. package/src/cli/avatar.ts +64 -0
  190. package/src/cli/capabilities.ts +22 -1
  191. package/src/cli/events.ts +320 -0
  192. package/src/cli/index.ts +1 -1
  193. package/src/config/env.ts +40 -0
  194. package/src/contracts/app-access.ts +52 -5
  195. package/src/contracts/app-approval.ts +8 -1
  196. package/src/contracts/app-details.ts +89 -0
  197. package/src/contracts/app-events.ts +51 -0
  198. package/src/contracts/app.ts +24 -5
  199. package/src/contracts/calendar-source.ts +274 -0
  200. package/src/contracts/calendar-view.ts +35 -0
  201. package/src/contracts/capabilities.ts +164 -3
  202. package/src/contracts/geo-admin.ts +2 -0
  203. package/src/contracts/geo-route.ts +0 -11
  204. package/src/contracts/geo.ts +14 -2
  205. package/src/contracts/index.ts +6 -0
  206. package/src/contracts/installable.ts +40 -0
  207. package/src/contracts/notification-types.ts +36 -9
  208. package/src/contracts/profile.ts +32 -0
  209. package/src/contracts/pwa-builds.ts +14 -0
  210. package/src/contracts/pwa-location.ts +38 -0
  211. package/src/contracts/pwa.ts +24 -3
  212. package/src/contracts/registry.ts +8 -0
  213. package/src/contracts/resource-clipboard.ts +35 -0
  214. package/src/contracts/resource-pages.ts +140 -0
  215. package/src/contracts/resource-resolution.ts +13 -0
  216. package/src/contracts/shared.ts +4 -0
  217. package/src/contracts/user-notifications.ts +2 -0
  218. package/src/events/app-event-delivery.ts +100 -0
  219. package/src/events/app-events.testing.ts +65 -0
  220. package/src/events/app-events.ts +134 -0
  221. package/src/events/core-user-created.ts +24 -0
  222. package/src/events/index.ts +9 -0
  223. package/src/events/testing.ts +2 -0
  224. package/src/index.ts +2 -1
  225. package/src/server/api/respond.ts +8 -3
  226. package/src/server/index.ts +1 -0
  227. package/src/server/middleware/app-access.ts +7 -6
  228. package/src/server/middleware/auth.ts +168 -66
  229. package/src/server/middleware/index.ts +1 -0
  230. package/src/server/middleware/invocation.ts +4 -2
  231. package/src/server/middleware/workload.ts +3 -3
  232. package/src/server/oauth-scope.ts +10 -0
  233. package/src/server/public-storage.ts +106 -0
  234. package/src/server/services/access.ts +8 -0
  235. package/src/services/accounts/app.ts +35 -3
  236. package/src/services/accounts/avatar.ts +342 -35
  237. package/src/services/accounts/base-group.ts +4 -5
  238. package/src/services/accounts/directory-shares.ts +157 -0
  239. package/src/services/accounts/entities.ts +50 -6
  240. package/src/services/accounts/groups.ts +3 -1
  241. package/src/services/accounts/identities.ts +64 -12
  242. package/src/services/accounts/local-groups.ts +1 -1
  243. package/src/services/accounts/users.ts +23 -15
  244. package/src/services/app-access-management.ts +519 -0
  245. package/src/services/app-access.ts +25 -7
  246. package/src/services/app-approval.ts +15 -4
  247. package/src/services/app-details.ts +224 -0
  248. package/src/services/app-discovery.ts +2 -1
  249. package/src/services/app-event-shapes.ts +139 -0
  250. package/src/services/app-events/accept.ts +116 -0
  251. package/src/services/app-events/admin.ts +250 -0
  252. package/src/services/app-events/declarations.ts +187 -0
  253. package/src/services/app-events/deliver.ts +218 -0
  254. package/src/services/app-events.ts +92 -0
  255. package/src/services/config-conflict.ts +11 -0
  256. package/src/services/core-user-events.ts +1 -0
  257. package/src/services/freeipa-config.ts +9 -0
  258. package/src/services/geo/admin.ts +4 -1
  259. package/src/services/geo/ledger.ts +47 -14
  260. package/src/services/geo/route-chain.ts +40 -18
  261. package/src/services/geo/route-estimate.ts +19 -0
  262. package/src/services/geo/route-plan.ts +0 -2
  263. package/src/services/geo/route-service.ts +57 -39
  264. package/src/services/geo/service.ts +51 -25
  265. package/src/services/identity/core-origin.ts +12 -0
  266. package/src/services/identity/index.ts +2 -1
  267. package/src/services/identity/invocation-actor.ts +50 -2
  268. package/src/services/identity/invocation-authority.ts +38 -1
  269. package/src/services/identity/invocation-token.ts +255 -50
  270. package/src/services/identity/issue-invocation.ts +9 -1
  271. package/src/services/identity/models-only-auth.ts +13 -4
  272. package/src/services/identity/nested-invocation.ts +128 -0
  273. package/src/services/image-metadata.ts +65 -1
  274. package/src/services/index.ts +24 -1
  275. package/src/services/ipa/sync.ts +6 -2
  276. package/src/services/ipa/users.ts +4 -1
  277. package/src/services/mandates/policy.ts +19 -12
  278. package/src/services/notifications/browser.ts +4 -0
  279. package/src/services/notifications/catalog.ts +5 -2
  280. package/src/services/notifications/platform.ts +7 -2
  281. package/src/services/notifications/user.ts +18 -7
  282. package/src/services/oauth-tokens.ts +28 -2
  283. package/src/services/object-store.ts +217 -0
  284. package/src/services/providers/local/users.ts +5 -0
  285. package/src/services/pwa-devices.ts +147 -37
  286. package/src/services/service-account-credentials.ts +5 -2
  287. package/src/services/session/index.ts +14 -0
  288. package/src/services/settings/core-settings.ts +91 -47
  289. package/src/services/settings/index.ts +8 -0
  290. package/src/services/settings/versions.ts +26 -0
  291. package/src/shared/ai-models.ts +274 -0
  292. package/src/shared/ai-quotas.ts +2 -2
  293. package/src/shared/ai-usage.ts +2 -2
  294. package/src/shared/index.ts +2 -0
  295. package/src/shared-text/documents.ts +25 -8
  296. package/src/shared-text/log.ts +1 -1
  297. package/src/shared-text/text-diff.ts +75 -0
  298. package/src/ssr/AdminArea.tsx +131 -0
  299. package/src/ssr/AdminLayout.tsx +11 -3
  300. package/src/ssr/AdminSidebar.tsx +7 -7
  301. package/src/ssr/AppAccessDenied.tsx +114 -0
  302. package/src/ssr/AppBadge.tsx +14 -0
  303. package/src/ssr/AppLaunchpadPanel.tsx +1 -1
  304. package/src/ssr/AppPwaRuntime.island.tsx +82 -0
  305. package/src/ssr/CloudShell.island.tsx +25 -4
  306. package/src/ssr/Layout.tsx +15 -7
  307. package/src/ssr/PwaLayout.tsx +73 -8
  308. package/src/ssr/PwaPartCount.island.tsx +23 -0
  309. package/src/ssr/PwaRuntime.island.tsx +85 -6
  310. package/src/ssr/SettingsSaveOutcome.tsx +58 -0
  311. package/src/ssr/SettingsSaveStatus.island.tsx +16 -0
  312. package/src/ssr/ShellRuntime.island.tsx +5 -0
  313. package/src/ssr/__snapshots__/Layout.unchanged.render.test.tsx.snap +3 -3
  314. package/src/ssr/__snapshots__/LayoutHelp.ssr.render.test.tsx.snap +1 -1
  315. package/src/ssr/admin-active-link.ts +16 -0
  316. package/src/ssr/admin-navigation.ts +26 -20
  317. package/src/ssr/app-badges.ts +31 -7
  318. package/src/ssr/app-pwa-document.tsx +16 -0
  319. package/src/ssr/app-pwa-messages.ts +17 -0
  320. package/src/ssr/index.ts +8 -0
  321. package/src/ssr/layout-context.ts +1 -1
  322. package/src/ssr/page-trail-runtime.ts +3 -1
  323. package/src/ssr/platform-messages.ts +9 -1
  324. package/src/ssr/pwa-messages.ts +10 -2
  325. package/src/ssr/rail-navigation.ts +5 -1
  326. package/src/ssr/settings-draft.ts +153 -0
  327. package/src/ssr/shell-data.tsx +2 -0
  328. package/src/styles/global.css +1 -0
  329. package/src/styles/profile-picture.css +270 -0
  330. package/src/styles/utilities-navigation.css +6 -0
  331. package/src/workflows/ai/policy.ts +49 -0
  332. package/src/workflows/ai/runtime.ts +131 -21
  333. package/src/workflows/ai/store.ts +283 -35
  334. package/src/workflows/ai/types.ts +8 -2
  335. package/src/workflows/ai-actions.ts +148 -120
  336. package/src/workflows/authoring.ts +31 -0
  337. package/src/workflows/contracts.ts +1 -0
  338. package/src/workflows/definition.ts +2 -0
  339. package/src/workflows/editor.ts +15 -0
  340. package/src/workflows/index.ts +2 -1
  341. package/src/workflows/language/compiler.ts +34 -3
  342. package/src/workflows/runtime/executor.ts +132 -33
  343. package/src/workflows/runtime/index.ts +2 -0
  344. package/src/workflows/runtime/ports.ts +5 -1
  345. package/src/workflows/store/actions.ts +1 -1
  346. package/src/workflows/store/definitions.ts +5 -0
  347. package/src/workflows/store/runs.ts +5 -3
  348. package/src/workflows/store/worker.ts +7 -2
  349. package/src/ai/model-pricing.ts +0 -43
  350. package/src/ai/model-request-settings.ts +0 -98
  351. package/src/ai/settings-messages.ts +0 -129
  352. package/src/shared/ai-model-request-settings.ts +0 -21
@@ -145,7 +145,7 @@ const createSchedule = async (config: {
145
145
  resourceKind: config.resourceKind,
146
146
  resourceId: config.resourceId,
147
147
  resourceLabel: config.label,
148
- detailHref: "/admin/settings?tab=ai",
148
+ detailHref: "/admin/ai?sec=general",
149
149
  },
150
150
  process: async (context) => {
151
151
  await config.submit(`slot:${context.slot.getTime()}`);
@@ -21,7 +21,7 @@ export type AiMemoryLearningRun = {
21
21
  conversationTitle: string;
22
22
  kind: AiMemoryLearningRunKind;
23
23
  status: AiMemoryLearningRunStatus;
24
- modelProfileId: string | null;
24
+ modelId: string | null;
25
25
  durationMs: number | null;
26
26
  addedCount: number;
27
27
  updatedCount: number;
@@ -79,7 +79,7 @@ const toRun = (row: RunRow): AiMemoryLearningRun => ({
79
79
  conversationTitle: row.conversation_title,
80
80
  kind: row.run_kind,
81
81
  status: row.status,
82
- modelProfileId: row.model_profile_id,
82
+ modelId: row.model_profile_id,
83
83
  durationMs: row.duration_ms,
84
84
  addedCount: row.added_count,
85
85
  updatedCount: row.updated_count,
@@ -107,7 +107,7 @@ export const aiMemoryLearningRuns = {
107
107
  turnId: string;
108
108
  runKind: AiMemoryLearningRunKind;
109
109
  evidenceKey?: string;
110
- modelProfileId: string;
110
+ modelId: string;
111
111
  accountedTokens: number;
112
112
  }): Promise<string | null> {
113
113
  if ((input.runKind === "workflow") !== Boolean(input.evidenceKey)) {
@@ -119,7 +119,7 @@ export const aiMemoryLearningRuns = {
119
119
  INSERT INTO ai.memory_learning_runs (
120
120
  short_id, user_id, conversation_id, turn_id, conversation_title, run_kind, model_profile_id, accounted_tokens
121
121
  )
122
- SELECT ${shortId}, ${input.userId}::uuid, c.id, turn.id, c.title, ${input.runKind}, ${input.modelProfileId}, ${input.accountedTokens}
122
+ SELECT ${shortId}, ${input.userId}::uuid, c.id, turn.id, c.title, ${input.runKind}, ${input.modelId}, ${input.accountedTokens}
123
123
  FROM ai.conversations c
124
124
  JOIN ai.turns turn ON turn.conversation_id = c.id AND turn.id = ${input.turnId}::uuid
125
125
  WHERE c.id = ${input.conversationId}::uuid AND c.created_by_user_id = ${input.userId}::uuid
@@ -136,7 +136,7 @@ export const aiMemoryLearningRuns = {
136
136
  short_id, user_id, conversation_id, turn_id, conversation_title, run_kind, evidence_key, model_profile_id, accounted_tokens
137
137
  )
138
138
  SELECT ${shortId}, ${input.userId}::uuid, c.id, turn.id, c.title, 'workflow', ${input.evidenceKey!},
139
- ${input.modelProfileId}, ${input.accountedTokens}
139
+ ${input.modelId}, ${input.accountedTokens}
140
140
  FROM ai.conversations c
141
141
  JOIN ai.turns turn ON turn.conversation_id = c.id AND turn.id = ${input.turnId}::uuid
142
142
  WHERE c.id = ${input.conversationId}::uuid AND c.created_by_user_id = ${input.userId}::uuid
@@ -432,7 +432,7 @@ export const learnAiMemoriesFromPrivateChats = async (
432
432
  const summary: AiMemoryLearningRunSummary = { scanned: 0, learned: 0, updated: 0, retired: 0, skipped: 0, failed: 0 };
433
433
  let resolved: AiResolvedModel;
434
434
  try {
435
- resolved = await (input.deps?.resolveModel ?? resolveAiBackgroundModel)();
435
+ resolved = await (input.deps?.resolveModel ?? resolveAiBackgroundModel)({ class: "fast" });
436
436
  } catch (error) {
437
437
  log.info("Memory learning skipped: no background model available", {
438
438
  error: error instanceof Error ? error.message : String(error),
@@ -500,7 +500,7 @@ export const learnAiMemoriesFromPrivateChats = async (
500
500
  conversationId: candidate.conversationId,
501
501
  turnId: candidate.turnId,
502
502
  runKind: "turn",
503
- modelProfileId: resolved.profile.id,
503
+ modelId: resolved.profile.id,
504
504
  accountedTokens: reservedTokens,
505
505
  });
506
506
  // Claiming the run also awaits the database: recheck immediately before inference.
@@ -617,7 +617,7 @@ export const learnAiMemoriesFromPrivateChats = async (
617
617
  turnId: context.source.candidate.turnId,
618
618
  runKind: "workflow",
619
619
  evidenceKey: workflowEvidenceKey(pattern),
620
- modelProfileId: resolved.profile.id,
620
+ modelId: resolved.profile.id,
621
621
  accountedTokens: reservedTokens,
622
622
  });
623
623
  if (!(await isAiWorkflowPatternEligible(pattern))) {
@@ -153,8 +153,7 @@ export const drainQueuedMessages = async (
153
153
  for (const row of rows) {
154
154
  try {
155
155
  const quotaSubject = aiChatAccessSubject(row.submission.runConfig.actor);
156
- if (isAssistantChatTurn(row.submission.runConfig) && quotaSubject)
157
- await aiQuotas.assertAllowed(quotaSubject, row.submission.modelProfileId);
156
+ if (isAssistantChatTurn(row.submission.runConfig) && quotaSubject) await aiQuotas.assertAllowed(quotaSubject, row.submission.modelId);
158
157
  await sql`UPDATE ai.queued_messages SET status='pending', error=NULL WHERE id=${row.id}::uuid AND status='failed' AND error IN ('quota_exhausted','quota_usage_unknown')`;
159
158
  const result = await aiConversations.submitChatTurn({ ...row.submission, queuedMessageId: row.id });
160
159
  await enqueue({ conversationId: row.conversation_id, turnId: result.turn.id });
package/src/ai/migrate.ts CHANGED
@@ -560,6 +560,8 @@ export const migrateCloudAi = async (): Promise<void> => {
560
560
  )
561
561
  `.simple();
562
562
  await sql`ALTER TABLE ai.conversation_resource_refs DROP COLUMN IF EXISTS occurrences`.simple();
563
+ await sql`ALTER TABLE ai.conversation_resource_refs ADD COLUMN IF NOT EXISTS context VARCHAR(200)`.simple();
564
+ await sql`ALTER TABLE ai.conversation_resource_refs ADD COLUMN IF NOT EXISTS status VARCHAR(60)`.simple();
563
565
 
564
566
  await sql`
565
567
  CREATE INDEX IF NOT EXISTS idx_ai_conversation_resource_refs_recent
@@ -1100,7 +1102,7 @@ export const migrateCloudAi = async (): Promise<void> => {
1100
1102
  `.simple();
1101
1103
 
1102
1104
  // Last model the user actually ran a turn with — preselected for new chats.
1103
- await sql`ALTER TABLE ai.user_prefs ADD COLUMN IF NOT EXISTS last_model_id TEXT NOT NULL DEFAULT ''`.simple();
1105
+ await sql`ALTER TABLE ai.user_prefs ADD COLUMN IF NOT EXISTS model_choice JSONB`.simple();
1104
1106
 
1105
1107
  await sql`
1106
1108
  CREATE TABLE IF NOT EXISTS ai.memories (
@@ -1880,7 +1882,7 @@ export const migrateCloudAi = async (): Promise<void> => {
1880
1882
  description TEXT NOT NULL DEFAULT '',
1881
1883
  icon TEXT NOT NULL DEFAULT 'ti ti-folders',
1882
1884
  instructions TEXT NOT NULL DEFAULT '',
1883
- default_model_profile_id TEXT,
1885
+ default_model JSONB,
1884
1886
  revision INTEGER NOT NULL DEFAULT 1,
1885
1887
  created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
1886
1888
  updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
@@ -2497,46 +2499,6 @@ export const migrateCloudAi = async (): Promise<void> => {
2497
2499
  FOR EACH ROW EXECUTE FUNCTION ai.live_project_group_membership_changed()
2498
2500
  `.simple();
2499
2501
 
2500
- // Provider API keys used to live inside the ai.model_profiles_json setting.
2501
- // A JSON setting is delivered to the admin UI in full, so every key rode
2502
- // along in the page payload; a per-profile row keeps them server-side and
2503
- // lets a deployment hold as many as it has profiles.
2504
- await sql`
2505
- CREATE TABLE IF NOT EXISTS ai.model_credentials (
2506
- profile_id TEXT PRIMARY KEY,
2507
- secret TEXT NOT NULL,
2508
- created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
2509
- updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
2510
- )
2511
- `.simple();
2512
-
2513
- await sql`
2514
- CREATE TABLE IF NOT EXISTS ai.model_request_headers (
2515
- profile_id TEXT PRIMARY KEY,
2516
- secret TEXT NOT NULL,
2517
- created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
2518
- updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
2519
- )
2520
- `.simple();
2521
-
2522
- // One-time move of keys that were stored inside the profiles setting. Once
2523
- // the blob holds no apiKey the split yields nothing and this is a no-op, so
2524
- // it is safe to re-run. Dynamic imports keep the module free of store deps.
2525
- {
2526
- const { coreSettings } = await import("../services");
2527
- const { setAiCredential, splitAiProfileCredentials } = await import("./credentials");
2528
- const profiles = await coreSettings.read<string>("ai.model_profiles_json");
2529
- if (profiles.stored === "unreadable") {
2530
- console.warn('[ai] cannot read setting "ai.model_profiles_json"; skipping credential split');
2531
- }
2532
- const split = profiles.stored === "unreadable" ? null : splitAiProfileCredentials(profiles.value ?? "[]");
2533
- if (split && split.credentials.length > 0) {
2534
- for (const { profileId, secret } of split.credentials) await setAiCredential(profileId, secret);
2535
- await coreSettings.set("ai.model_profiles_json", split.profilesJson);
2536
- console.log(` ✓ moved ${split.credentials.length} AI provider key(s) out of ai.model_profiles_json`);
2537
- }
2538
- }
2539
-
2540
2502
  await sql`ALTER TABLE ai.structured_runs
2541
2503
  ADD COLUMN IF NOT EXISTS user_id UUID REFERENCES auth.users(id) ON DELETE SET NULL,
2542
2504
  ADD COLUMN IF NOT EXISTS conversation_id UUID REFERENCES ai.conversations(id) ON DELETE SET NULL,
@@ -2609,6 +2571,7 @@ export const migrateCloudAi = async (): Promise<void> => {
2609
2571
 
2610
2572
  await migrateAiModelAccess();
2611
2573
  await migrateAiQuotas();
2574
+ await migrateAiModelClasses();
2612
2575
 
2613
2576
  await sql`CREATE OR REPLACE FUNCTION ai.sidebar_tool_label(blocks jsonb) RETURNS jsonb AS $$
2614
2577
  SELECT jsonb_build_object('label', left(COALESCE(block->'presentation'->>'title', block->>'name'),200), 'status', block->>'status')
@@ -2733,3 +2696,73 @@ const backfillApprovalRequests = async (): Promise<void> => {
2733
2696
  if (!count) return;
2734
2697
  }
2735
2698
  };
2699
+
2700
+ /** Breaking cut: configuration is deliberately discarded once; history stays intact. */
2701
+ export const migrateAiModelClasses = async (): Promise<void> => {
2702
+ await sql.begin(migrateAiModelClassesInTransaction);
2703
+ };
2704
+
2705
+ export const migrateAiModelClassesInTransaction = async (db: typeof sql): Promise<void> => {
2706
+ await db`SELECT pg_advisory_xact_lock(hashtext('ai.model-classes-v9'))`;
2707
+ const [state] = await db<{ exists: boolean }[]>`SELECT to_regclass('ai.providers') IS NOT NULL AS exists`;
2708
+ if (!state?.exists) {
2709
+ await db`DELETE FROM auth.access WHERE id IN (SELECT access_id FROM ai.model_access)`;
2710
+ await db`DELETE FROM ai.model_access_resources`;
2711
+ await db`DO $$ BEGIN
2712
+ IF EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema='ai' AND table_name='model_access_resources' AND column_name='profile_id') THEN
2713
+ ALTER TABLE ai.model_access_resources RENAME COLUMN profile_id TO model_id;
2714
+ ALTER TABLE ai.model_access RENAME COLUMN profile_id TO model_id;
2715
+ END IF;
2716
+ END $$`;
2717
+ await db`UPDATE ai.cost_config SET rules = COALESCE((SELECT jsonb_agg(rule) FROM jsonb_array_elements(rules) rule WHERE rule->>'scope' = '*'), '[]'::jsonb), revision = revision + 1`;
2718
+ await db`DELETE FROM settings.entries WHERE key IN ('ai.model_profiles_json','ai.model_profiles','ai.default_model_id','ai.background_model_id','ai.workflow_model_id','ai.vision_model_id','ai.audio_model_id')`;
2719
+ await db`DROP TABLE IF EXISTS ai.model_credentials`;
2720
+ await db`DROP TABLE IF EXISTS ai.model_request_headers`;
2721
+ await db`ALTER TABLE ai.projects DROP COLUMN IF EXISTS default_model_profile_id`;
2722
+ await db`ALTER TABLE ai.user_prefs DROP COLUMN IF EXISTS last_model_id`;
2723
+ }
2724
+ await db`ALTER TABLE ai.projects ADD COLUMN IF NOT EXISTS default_model JSONB`;
2725
+ await db`ALTER TABLE ai.user_prefs ADD COLUMN IF NOT EXISTS model_choice JSONB`;
2726
+ await db`ALTER TABLE settings.entries ADD COLUMN IF NOT EXISTS updated_by UUID REFERENCES auth.users(id) ON DELETE SET NULL`;
2727
+ await db`CREATE TABLE IF NOT EXISTS ai.model_change_notices (
2728
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
2729
+ name TEXT NOT NULL,retargeted BOOLEAN NOT NULL,created_at TIMESTAMPTZ NOT NULL DEFAULT now()
2730
+ )`;
2731
+ await db`CREATE TABLE IF NOT EXISTS ai.providers (
2732
+ id TEXT PRIMARY KEY, type TEXT NOT NULL, label TEXT NOT NULL, base_url TEXT, data_boundary TEXT NOT NULL CHECK(data_boundary IN ('hosted','private')),
2733
+ enabled BOOLEAN NOT NULL DEFAULT true, logo TEXT, status TEXT NOT NULL DEFAULT 'unknown' CHECK(status IN ('unknown','ok','unreachable','key_rejected','error')),
2734
+ status_code TEXT, status_message TEXT, status_checked_at TIMESTAMPTZ,
2735
+ revision INTEGER NOT NULL DEFAULT 1, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
2736
+ updated_by UUID REFERENCES auth.users(id) ON DELETE SET NULL
2737
+ )`;
2738
+ await db`CREATE TABLE IF NOT EXISTS ai.provider_credentials (
2739
+ provider_id TEXT PRIMARY KEY REFERENCES ai.providers(id) ON DELETE CASCADE, secret TEXT NOT NULL, updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
2740
+ )`;
2741
+ await db`CREATE TABLE IF NOT EXISTS ai.provider_request_headers (
2742
+ provider_id TEXT PRIMARY KEY REFERENCES ai.providers(id) ON DELETE CASCADE, secret TEXT NOT NULL, updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
2743
+ )`;
2744
+ await db`CREATE TABLE IF NOT EXISTS ai.models (
2745
+ id TEXT PRIMARY KEY, provider_id TEXT NOT NULL REFERENCES ai.providers(id) ON DELETE CASCADE, provider_model TEXT NOT NULL,
2746
+ label TEXT NOT NULL, description TEXT NOT NULL DEFAULT '', kind TEXT NOT NULL CHECK(kind IN ('chat','transcription')),
2747
+ capabilities JSONB NOT NULL DEFAULT '[]', context_window INTEGER, max_output_tokens INTEGER, temperature DOUBLE PRECISION, extra_body JSONB,
2748
+ max_loaded_tools INTEGER, max_tool_rounds INTEGER, pricing JSONB, icon JSONB NOT NULL DEFAULT '{"kind":"provider"}', enabled BOOLEAN NOT NULL DEFAULT true,
2749
+ revision INTEGER NOT NULL DEFAULT 1, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
2750
+ updated_by UUID REFERENCES auth.users(id) ON DELETE SET NULL
2751
+ )`;
2752
+ await db`CREATE TABLE IF NOT EXISTS ai.model_variants (
2753
+ id TEXT PRIMARY KEY, model_id TEXT NOT NULL REFERENCES ai.models(id) ON DELETE CASCADE, slug TEXT NOT NULL, label TEXT NOT NULL,
2754
+ hint TEXT NOT NULL DEFAULT '', reasoning_effort TEXT, max_output_tokens INTEGER, pricing JSONB, extra_body JSONB,
2755
+ show_in_picker BOOLEAN NOT NULL DEFAULT true, position INTEGER NOT NULL DEFAULT 0,
2756
+ revision INTEGER NOT NULL DEFAULT 1, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
2757
+ updated_by UUID REFERENCES auth.users(id) ON DELETE SET NULL, UNIQUE(model_id,slug),
2758
+ CHECK(id = CASE WHEN slug = 'standard' THEN model_id ELSE model_id || ':' || slug END)
2759
+ )`;
2760
+ await db`CREATE TABLE IF NOT EXISTS ai.model_assignments (
2761
+ slot TEXT PRIMARY KEY CHECK(slot IN ('fast','balanced','thorough','maximum','vision','audio')),
2762
+ variant_id TEXT REFERENCES ai.model_variants(id), revision INTEGER NOT NULL DEFAULT 1,
2763
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_by UUID REFERENCES auth.users(id) ON DELETE SET NULL
2764
+ )`;
2765
+ for (const slot of ["fast", "balanced", "thorough", "maximum", "vision", "audio"])
2766
+ await db`INSERT INTO ai.model_assignments(slot) VALUES(${slot}) ON CONFLICT DO NOTHING`;
2767
+ await db`ALTER TABLE ai.inference_calls ADD COLUMN IF NOT EXISTS requested_class TEXT, ADD COLUMN IF NOT EXISTS resolved_class TEXT, ADD COLUMN IF NOT EXISTS model_id TEXT`;
2768
+ };
@@ -1,30 +1,16 @@
1
1
  import { sql } from "bun";
2
- import { decryptValue } from "../services/settings/crypto";
3
- import { aiModelAccess } from "./model-access";
4
- import { parseAiModelProfiles } from "./settings";
5
2
 
6
3
  export const migrateAiModelAccess = async (): Promise<void> => {
7
4
  await sql.begin(async (tx) => {
8
5
  await tx`CREATE SEQUENCE IF NOT EXISTS ai.model_access_revision`;
9
6
  await tx`CREATE TABLE IF NOT EXISTS ai.model_access_resources (
10
- profile_id TEXT PRIMARY KEY,
7
+ model_id TEXT PRIMARY KEY,
11
8
  revision BIGINT NOT NULL DEFAULT nextval('ai.model_access_revision')
12
9
  )`;
13
10
  await tx`CREATE TABLE IF NOT EXISTS ai.model_access (
14
- profile_id TEXT NOT NULL REFERENCES ai.model_access_resources(profile_id) ON DELETE CASCADE,
11
+ model_id TEXT NOT NULL REFERENCES ai.model_access_resources(model_id) ON DELETE CASCADE,
15
12
  access_id UUID NOT NULL REFERENCES auth.access(id) ON DELETE CASCADE,
16
- PRIMARY KEY (profile_id, access_id), UNIQUE (access_id)
13
+ PRIMARY KEY (model_id, access_id), UNIQUE (access_id)
17
14
  )`;
18
- // Read authoritative configuration, not a possibly stale Redis settings snapshot.
19
- const [setting] = await tx<{ value: string }[]>`SELECT value FROM settings.entries WHERE key = 'ai.model_profiles_json' FOR UPDATE`;
20
- const raw = setting ? await decryptValue(setting.value) : "[]";
21
- if (typeof raw !== "string") throw new Error("Model profiles must be a JSON array string.");
22
- const parsed = parseAiModelProfiles(raw);
23
- if (parsed.error) throw new Error(parsed.error.message);
24
- await aiModelAccess.syncProfiles(
25
- parsed.profiles.map((profile) => profile.id),
26
- [],
27
- tx,
28
- );
29
15
  });
30
16
  };
@@ -1,50 +1,15 @@
1
1
  import { sql } from "bun";
2
- import { z } from "zod";
3
- import { type AccessEntry, type Principal, PrincipalSchema, type ServiceAccountKind } from "../contracts/shared";
2
+ import type { AccessEntry, Principal, ServiceAccountKind } from "../contracts/shared";
4
3
  import { type AccessSubject, buildAccessPrincipalCondition, createAccess } from "../server/services/access";
5
4
  import { toPgTextArray } from "../services/postgres";
5
+ import type { AiModelAccessDraft } from "../shared/ai-models";
6
6
  import type { AiSettingsError } from "./types";
7
7
 
8
8
  export type AiModelAccessState = { entries: AccessEntry[]; revision: number };
9
9
  export type AiModelAccessMap = Record<string, AiModelAccessState>;
10
10
 
11
- const GrantSchema = z
12
- .object({
13
- principal: PrincipalSchema.refine((principal) => principal.type !== "public", "Assistant models cannot be public."),
14
- permission: z.literal("read"),
15
- })
16
- .strict();
17
- export const AiModelAccessDraftSchema = z
18
- .object({
19
- expectedRevision: z.number().int().positive().max(Number.MAX_SAFE_INTEGER).nullable(),
20
- sourceProfileId: z
21
- .string()
22
- .trim()
23
- .regex(/^[a-z0-9][a-z0-9._-]*$/)
24
- .optional(),
25
- entries: z.array(GrantSchema),
26
- })
27
- .strict();
28
- export type AiModelAccessDraft = z.infer<typeof AiModelAccessDraftSchema>;
29
- export type AiModelAccessChange = AiModelAccessDraft & { profileId: string };
30
-
31
- /** Transport-only draft fields never enter the provider setting. */
32
- export const splitAiModelAccess = (profilesJson: string): { profilesJson: string; changes: AiModelAccessChange[] } => {
33
- const profiles = z.array(z.record(z.string(), z.unknown())).parse(JSON.parse(profilesJson));
34
- const changes: AiModelAccessChange[] = [];
35
- const stored = profiles.map(({ assistantAccess, ...profile }) => {
36
- if (assistantAccess !== undefined) {
37
- const profileId = z
38
- .string()
39
- .trim()
40
- .regex(/^[a-z0-9][a-z0-9._-]*$/)
41
- .parse(profile.id);
42
- changes.push({ profileId, ...AiModelAccessDraftSchema.parse(assistantAccess) });
43
- }
44
- return profile;
45
- });
46
- return { profilesJson: JSON.stringify(stored), changes };
47
- };
11
+ export { type AiModelAccessDraft, AiModelAccessDraftSchema } from "../shared/ai-models";
12
+ export type AiModelAccessChange = AiModelAccessDraft & { modelId: string };
48
13
 
49
14
  export class AiModelAccessConflict extends Error {
50
15
  constructor() {
@@ -61,14 +26,14 @@ const principalKey = (principal: Principal): string => {
61
26
  return principal.type;
62
27
  };
63
28
 
64
- const grant = async (profileId: string, principal: Principal, db: typeof sql): Promise<void> => {
29
+ const grant = async (modelId: string, principal: Principal, db: typeof sql): Promise<void> => {
65
30
  const result = await createAccess({ principal, permission: "read" }, db);
66
31
  if (!result.ok) throw new AiModelAccessInvalid(result.error.message);
67
- await db`INSERT INTO ai.model_access (profile_id, access_id) VALUES (${profileId}, ${result.data.id}::uuid)`;
32
+ await db`INSERT INTO ai.model_access (model_id, access_id) VALUES (${modelId}, ${result.data.id}::uuid)`;
68
33
  };
69
34
 
70
- const allowedProfileIds = async (profileIds: string[], subject: AccessSubject | null): Promise<Set<string>> => {
71
- if (!subject || profileIds.length === 0) return new Set();
35
+ const allowedModelIds = async (modelIds: string[], subject: AccessSubject | null): Promise<Set<string>> => {
36
+ if (!subject || modelIds.length === 0) return new Set();
72
37
  const match = buildAccessPrincipalCondition({
73
38
  subject,
74
39
  columns: {
@@ -78,34 +43,34 @@ const allowedProfileIds = async (profileIds: string[], subject: AccessSubject |
78
43
  authenticatedOnly: sql`access.authenticated_only`,
79
44
  },
80
45
  });
81
- const rows = await sql<{ profile_id: string }[]>`
82
- SELECT DISTINCT model.profile_id FROM ai.model_access model JOIN auth.access access ON access.id = model.access_id
83
- WHERE model.profile_id = ANY(${toPgTextArray(profileIds)}::text[])
46
+ const rows = await sql<{ model_id: string }[]>`
47
+ SELECT DISTINCT model.model_id FROM ai.model_access model JOIN auth.access access ON access.id = model.access_id
48
+ WHERE model.model_id = ANY(${toPgTextArray(modelIds)}::text[])
84
49
  AND access.permission IN ('read', 'write', 'admin') AND ${match}
85
50
  `;
86
- return new Set(rows.map((row) => row.profile_id));
51
+ return new Set(rows.map((row) => row.model_id));
87
52
  };
88
53
 
89
54
  /** Assistant chat authorization only. Global/background model resolution does not call this service. */
90
55
  export const aiModelAccess = {
91
- filterModels: async <T extends { id: string }>(models: T[], subject: AccessSubject | null): Promise<T[]> => {
92
- const allowed = await allowedProfileIds(
93
- models.map((model) => model.id),
56
+ filterModels: async <T extends { id: string; modelId: string }>(models: T[], subject: AccessSubject | null): Promise<T[]> => {
57
+ const allowed = await allowedModelIds(
58
+ models.map((model) => model.modelId),
94
59
  subject,
95
60
  );
96
- return models.filter((model) => allowed.has(model.id));
61
+ return models.filter((model) => allowed.has(model.modelId));
97
62
  },
98
- assertAllowed: async (profileId: string, subject: AccessSubject | null): Promise<void> => {
99
- if ((await allowedProfileIds([profileId], subject)).has(profileId)) return;
63
+ assertAllowed: async (modelId: string, subject: AccessSubject | null): Promise<void> => {
64
+ if ((await allowedModelIds([modelId], subject)).has(modelId)) return;
100
65
  const message = "You do not have permission to use this model in Assistant. Choose another model.";
101
66
  throw Object.assign(new Error(message), {
102
- aiError: { code: "model_access_denied", message, fields: { modelProfileId: message } } satisfies AiSettingsError,
67
+ aiError: { code: "model_policy_mismatch", message, fields: { model: message } } satisfies AiSettingsError,
103
68
  });
104
69
  },
105
- listForAdmin: async (): Promise<AiModelAccessMap> => {
106
- const rows = await sql<
70
+ listForAdmin: async (db = sql): Promise<AiModelAccessMap> => {
71
+ const rows = await db<
107
72
  {
108
- profile_id: string;
73
+ model_id: string;
109
74
  revision: number | string;
110
75
  id: string | null;
111
76
  created_at: Date | null;
@@ -118,21 +83,21 @@ export const aiModelAccess = {
118
83
  service_account_kind: ServiceAccountKind | null;
119
84
  }[]
120
85
  >`
121
- SELECT resource.profile_id, resource.revision, access.id, access.created_at,
86
+ SELECT resource.model_id, resource.revision, access.id, access.created_at,
122
87
  access.user_id, access.group_id, access.service_account_id, access.authenticated_only,
123
88
  COALESCE(NULLIF(users.display_name, ''), users.uid, groups.name, service_accounts.name) AS display_name,
124
89
  users.avatar_hash, service_accounts.kind AS service_account_kind
125
- FROM ai.model_access_resources resource LEFT JOIN ai.model_access model ON model.profile_id = resource.profile_id
90
+ FROM ai.model_access_resources resource LEFT JOIN ai.model_access model ON model.model_id = resource.model_id
126
91
  LEFT JOIN auth.access access ON access.id = model.access_id
127
92
  LEFT JOIN auth.users users ON users.id = access.user_id
128
93
  LEFT JOIN auth.groups groups ON groups.id = access.group_id
129
94
  LEFT JOIN auth.service_accounts service_accounts ON service_accounts.id = access.service_account_id
130
- ORDER BY resource.profile_id, access.created_at, access.id
95
+ ORDER BY resource.model_id, access.created_at, access.id
131
96
  `;
132
97
  const result = new Map<string, AiModelAccessState>();
133
98
  for (const row of rows) {
134
- const state = result.get(row.profile_id) ?? { entries: [], revision: Number(row.revision) };
135
- result.set(row.profile_id, state);
99
+ const state = result.get(row.model_id) ?? { entries: [], revision: Number(row.revision) };
100
+ result.set(row.model_id, state);
136
101
  if (!row.id || !row.created_at) continue;
137
102
  const principal: Principal = row.user_id
138
103
  ? { type: "user", userId: row.user_id }
@@ -156,41 +121,35 @@ export const aiModelAccess = {
156
121
  return Object.fromEntries(result);
157
122
  },
158
123
 
159
- /** Called in the settings transaction. Existing grants survive edits that omit the ACL draft. */
160
- syncProfiles: async (profileIds: readonly string[], changes: readonly AiModelAccessChange[], db: typeof sql): Promise<void> => {
161
- // Serialize lifecycle changes (including a previously absent profile) with saves and migration seeding.
124
+ /** Called in the catalog transaction. Existing grants survive edits that omit the ACL draft. */
125
+ syncModels: async (modelIds: readonly string[], changes: readonly AiModelAccessChange[], db: typeof sql): Promise<void> => {
126
+ // Serialize lifecycle changes (including a previously absent model) with catalog saves.
162
127
  await db`LOCK TABLE ai.model_access_resources IN SHARE ROW EXCLUSIVE MODE`;
163
- const current = await db<
164
- { profile_id: string; revision: number | string }[]
165
- >`SELECT profile_id, revision FROM ai.model_access_resources`;
166
- const revisions = new Map(current.map((row) => [row.profile_id, Number(row.revision)]));
167
- const keep = new Set(profileIds);
128
+ const current = await db<{ model_id: string; revision: number | string }[]>`SELECT model_id, revision FROM ai.model_access_resources`;
129
+ const revisions = new Map(current.map((row) => [row.model_id, Number(row.revision)]));
130
+ const keep = new Set(modelIds);
168
131
  for (const change of changes) {
169
- if (!keep.has(change.profileId)) throw new Error("Permissions reference an unknown model profile.");
170
- const sourceId = change.sourceProfileId ?? change.profileId;
171
- if ((revisions.get(sourceId) ?? null) !== change.expectedRevision) throw new AiModelAccessConflict();
172
- if (change.sourceProfileId && change.sourceProfileId !== change.profileId && revisions.has(change.profileId)) {
173
- throw new AiModelAccessConflict();
174
- }
132
+ if (!keep.has(change.modelId)) throw new Error("Permissions reference an unknown model.");
133
+ if ((revisions.get(change.modelId) ?? null) !== change.expectedRevision) throw new AiModelAccessConflict();
175
134
  }
176
- for (const profileId of keep) {
177
- if (revisions.has(profileId)) continue;
178
- await db`INSERT INTO ai.model_access_resources (profile_id) VALUES (${profileId})`;
179
- await grant(profileId, { type: "authenticated" }, db);
135
+ for (const modelId of keep) {
136
+ if (revisions.has(modelId)) continue;
137
+ await db`INSERT INTO ai.model_access_resources (model_id) VALUES (${modelId})`;
138
+ await grant(modelId, { type: "authenticated" }, db);
180
139
  }
181
140
  for (const change of changes) {
182
- await db`DELETE FROM auth.access WHERE id IN (SELECT access_id FROM ai.model_access WHERE profile_id = ${change.profileId})`;
141
+ await db`DELETE FROM auth.access WHERE id IN (SELECT access_id FROM ai.model_access WHERE model_id = ${change.modelId})`;
183
142
  const seen = new Set<string>();
184
143
  for (const entry of change.entries) {
185
144
  const key = principalKey(entry.principal);
186
145
  if (seen.has(key)) continue;
187
146
  seen.add(key);
188
- await grant(change.profileId, entry.principal, db);
147
+ await grant(change.modelId, entry.principal, db);
189
148
  }
190
- await db`UPDATE ai.model_access_resources SET revision = nextval('ai.model_access_revision') WHERE profile_id = ${change.profileId}`;
149
+ await db`UPDATE ai.model_access_resources SET revision = nextval('ai.model_access_revision') WHERE model_id = ${change.modelId}`;
191
150
  }
192
151
  const ids = toPgTextArray([...keep]);
193
- await db`DELETE FROM auth.access WHERE id IN (SELECT access_id FROM ai.model_access WHERE profile_id <> ALL(${ids}::text[]))`;
194
- await db`DELETE FROM ai.model_access_resources WHERE profile_id <> ALL(${ids}::text[])`;
152
+ await db`DELETE FROM auth.access WHERE id IN (SELECT access_id FROM ai.model_access WHERE model_id <> ALL(${ids}::text[]))`;
153
+ await db`DELETE FROM ai.model_access_resources WHERE model_id <> ALL(${ids}::text[])`;
195
154
  },
196
155
  };
@@ -0,0 +1,134 @@
1
+ import { ChoiceSlider, IconButton, useLocale } from "@k2b/ui";
2
+ import { createSignal, type JSX, Show } from "solid-js";
3
+ import { AI_MODEL_CLASSES, type AiModelClass, type AiPeopleModelIcon } from "../shared/ai-models";
4
+ import { aiModelChooserMessages } from "./model-chooser-messages";
5
+ import { AiModelIcon } from "./model-icon";
6
+
7
+ export type AiModelCardStop = {
8
+ class: AiModelClass;
9
+ /** The model behind the class, as people see it: short name and icon. */
10
+ name: string;
11
+ icon: AiPeopleModelIcon;
12
+ /** Why the class cannot be chosen now; replaces the subtitle in the line. */
13
+ unavailable?: string;
14
+ };
15
+
16
+ const asClass = (value: string | null): AiModelClass | null => AI_MODEL_CLASSES.find((modelClass) => modelClass === value) ?? null;
17
+
18
+ export const aiModelClassLabel = (locale: string, modelClass: AiModelClass): string =>
19
+ aiModelChooserMessages.resolve([locale]).t[`class_${modelClass}`];
20
+ export const aiModelClassSubtitle = (locale: string, modelClass: AiModelClass): string =>
21
+ aiModelChooserMessages.resolve([locale]).t[`classSub_${modelClass}`];
22
+
23
+ /**
24
+ * The model card: the class in accent colour, the model behind it, one fixed line that always says what the class
25
+ * is for, and the slider over the assigned classes. Off the classes it shows "Own choice" with the model's name.
26
+ * The preview in the AI area renders the same card without interaction.
27
+ */
28
+ export function AiModelCard(props: {
29
+ stops: readonly AiModelCardStop[];
30
+ /** The chosen class, or `null` for a model outside the classes. */
31
+ value: AiModelClass | null;
32
+ /** The model shown off the classes: "Name · Variant", its icon and its subtitle. */
33
+ own?: { name: string; icon: AiPeopleModelIcon; description: string };
34
+ /** A problem with the current model, such as a used-up budget; shown in warning colour. */
35
+ issue?: string;
36
+ /** Replaces the subtitle when the chosen class is empty and another one answers. */
37
+ note?: string;
38
+ onChoose?: (modelClass: AiModelClass) => void;
39
+ onOpenList?: () => void;
40
+ disabled?: boolean;
41
+ /** A static preview: same layout, no controls. */
42
+ preview?: boolean;
43
+ size?: "md" | "lg";
44
+ /** The slider's id, so the opener can move focus to it. */
45
+ sliderId?: string;
46
+ }): JSX.Element {
47
+ const locale = useLocale();
48
+ const t = () => aiModelChooserMessages.resolve([locale()]).t;
49
+ const [active, setActive] = createSignal<AiModelClass | null>(null);
50
+ const shownClass = () => active() ?? props.value;
51
+ const stop = () => props.stops.find((entry) => entry.class === shownClass());
52
+ const level = () => (shownClass() ? aiModelClassLabel(locale(), shownClass()!) : t().ownChoice);
53
+ const name = () => stop()?.name ?? props.own?.name ?? "";
54
+ const icon = () => stop()?.icon ?? props.own?.icon;
55
+ const warn = () => Boolean(props.issue) && active() === null;
56
+ const options = () =>
57
+ props.stops.map((entry) => ({
58
+ value: entry.class,
59
+ label: aiModelClassLabel(locale(), entry.class),
60
+ description:
61
+ entry.class === props.value && props.note && active() === null ? props.note : aiModelClassSubtitle(locale(), entry.class),
62
+ disabledReason: entry.unavailable,
63
+ valueText: [
64
+ aiModelClassLabel(locale(), entry.class),
65
+ entry.name,
66
+ entry.unavailable ?? aiModelClassSubtitle(locale(), entry.class),
67
+ ].join(", "),
68
+ }));
69
+ const atDefault = () => props.value === "balanced" || !props.stops.some((entry) => entry.class === "balanced");
70
+ return (
71
+ <div class="grid gap-0.5" data-ai-model-card={props.preview ? "preview" : ""} aria-hidden={props.preview ? "true" : undefined}>
72
+ <div class="grid grid-cols-[2rem_minmax(0,1fr)_2rem] items-center">
73
+ <i class="ti ti-gauge grid place-items-center text-(--k2b-text-muted)" aria-hidden="true" />
74
+ <span
75
+ class="truncate text-center text-[1.0625rem] leading-snug font-semibold tracking-tight"
76
+ classList={{ "text-(--k2b-action)": !warn(), "text-(--k2b-warning-text)": warn() }}
77
+ >
78
+ {level()}
79
+ </span>
80
+ <span class="grid place-items-center" classList={{ invisible: atDefault() || props.preview || props.disabled }}>
81
+ <IconButton
82
+ type="button"
83
+ variant="ghost"
84
+ size="sm"
85
+ label={t().resetTo}
86
+ tabIndex={atDefault() || props.preview ? -1 : undefined}
87
+ onClick={() => props.onChoose?.("balanced")}
88
+ >
89
+ <i class="ti ti-arrow-back-up" aria-hidden="true" />
90
+ </IconButton>
91
+ </span>
92
+ </div>
93
+ <div class="flex justify-center">
94
+ <Show
95
+ when={props.onOpenList && !props.preview}
96
+ fallback={
97
+ <span class="inline-flex max-w-full items-center gap-1.5 px-1.5 py-0.5 text-sm text-(--k2b-text-secondary)">
98
+ <Show when={icon()}>{(current) => <AiModelIcon icon={current()} size="xs" />}</Show>
99
+ <span class="truncate">{name()}</span>
100
+ </span>
101
+ }
102
+ >
103
+ <button
104
+ type="button"
105
+ class="inline-flex max-w-full items-center gap-1.5 rounded-md px-1.5 py-0.5 text-sm text-(--k2b-text-secondary) hover:bg-(--k2b-surface-muted) hover:text-(--k2b-text) focus-visible:outline-2 focus-visible:outline-(--k2b-focus-ring)"
106
+ aria-label={`${t().allModels}: ${name()}`}
107
+ disabled={props.disabled}
108
+ onClick={() => props.onOpenList?.()}
109
+ >
110
+ <Show when={icon()}>{(current) => <AiModelIcon icon={current()} size="xs" />}</Show>
111
+ <span class="truncate">{name()}</span>
112
+ <i class="ti ti-chevron-right text-xs" aria-hidden="true" />
113
+ </button>
114
+ </Show>
115
+ </div>
116
+ <div>
117
+ <ChoiceSlider
118
+ id={props.sliderId}
119
+ aria-label={t().sliderAria}
120
+ options={options()}
121
+ value={() => props.value}
122
+ description={props.issue ?? props.own?.description}
123
+ disabled={props.disabled || props.preview}
124
+ size={props.size}
125
+ onActiveChange={(value) => setActive(asClass(value))}
126
+ onValueChange={(value) => {
127
+ const modelClass = asClass(value);
128
+ if (modelClass) props.onChoose?.(modelClass);
129
+ }}
130
+ />
131
+ </div>
132
+ </div>
133
+ );
134
+ }