@k2b/cloud 0.43.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 (267) hide show
  1. package/package.json +7 -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/PermissionEditor.tsx +9 -3
  21. package/src/access/PrincipalPicker.tsx +14 -4
  22. package/src/access/ResourceApiKeys.tsx +3 -4
  23. package/src/access/app-access-messages.ts +6 -0
  24. package/src/account/ProfilePictureDialog.tsx +699 -0
  25. package/src/account/profile-picture-messages.ts +269 -0
  26. package/src/account/ui.ts +7 -0
  27. package/src/ai/admin.ts +3 -0
  28. package/src/ai/app-frame.ts +12 -0
  29. package/src/ai/application-tasks.ts +101 -0
  30. package/src/ai/assistant-models.ts +126 -64
  31. package/src/ai/capabilities.ts +19 -4
  32. package/src/ai/chart-block.ts +63 -30
  33. package/src/ai/chat/chart-block.tsx +12 -3
  34. package/src/ai/chat/composer-adapter.ts +8 -7
  35. package/src/ai/chat/message-actions.tsx +1 -1
  36. package/src/ai/chat/message-utils.ts +2 -2
  37. package/src/ai/chat/presentation.tsx +22 -1
  38. package/src/ai/chat-quotas.ts +5 -4
  39. package/src/ai/chat-tasks.ts +2 -2
  40. package/src/ai/client/controller.ts +11 -10
  41. package/src/ai/client/projection.ts +3 -3
  42. package/src/ai/compaction.ts +3 -3
  43. package/src/ai/credentials.ts +13 -114
  44. package/src/ai/default-tool-contracts.ts +4 -3
  45. package/src/ai/dictation-runtime.ts +4 -4
  46. package/src/ai/enrich.ts +2 -2
  47. package/src/ai/executor.ts +31 -22
  48. package/src/ai/http.ts +17 -29
  49. package/src/ai/index.ts +11 -5
  50. package/src/ai/inference-calls.ts +5 -4
  51. package/src/ai/maintenance.ts +1 -1
  52. package/src/ai/memory-learning-runs.ts +5 -5
  53. package/src/ai/memory-learning.ts +3 -3
  54. package/src/ai/message-queue.ts +1 -2
  55. package/src/ai/migrate.ts +75 -42
  56. package/src/ai/model-access-migrate.ts +3 -17
  57. package/src/ai/model-access.ts +44 -85
  58. package/src/ai/model-card.tsx +134 -0
  59. package/src/ai/model-catalog.ts +359 -0
  60. package/src/ai/model-chooser-messages.ts +88 -0
  61. package/src/ai/model-chooser.tsx +577 -0
  62. package/src/ai/model-delete.ts +234 -0
  63. package/src/ai/model-fixture.ts +39 -0
  64. package/src/ai/model-icon.tsx +36 -0
  65. package/src/ai/model-overview.ts +53 -0
  66. package/src/ai/model-usage.ts +45 -0
  67. package/src/ai/openai-api-messages.ts +0 -4
  68. package/src/ai/openai-api.ts +26 -9
  69. package/src/ai/personal-agent.ts +1 -1
  70. package/src/ai/prefs.ts +12 -10
  71. package/src/ai/projects-routes.ts +2 -1
  72. package/src/ai/projects.ts +13 -12
  73. package/src/ai/protocol.ts +2 -2
  74. package/src/ai/provider-connections.ts +161 -0
  75. package/src/ai/provider-errors.ts +55 -6
  76. package/src/ai/provider-fetch.ts +4 -0
  77. package/src/ai/provider.ts +1 -1
  78. package/src/ai/quota-limits.ts +36 -0
  79. package/src/ai/quota-provider.ts +4 -2
  80. package/src/ai/quota-report.ts +7 -4
  81. package/src/ai/quotas.ts +19 -8
  82. package/src/ai/recent-resources.ts +6 -0
  83. package/src/ai/request-headers.ts +14 -87
  84. package/src/ai/resource-refs.ts +6 -2
  85. package/src/ai/routes.ts +17 -18
  86. package/src/ai/runtime.ts +50 -22
  87. package/src/ai/settings.ts +93 -580
  88. package/src/ai/store.ts +45 -23
  89. package/src/ai/stream.ts +1 -1
  90. package/src/ai/structured-runs.ts +2 -2
  91. package/src/ai/structured.ts +21 -44
  92. package/src/ai/task-contracts.ts +25 -5
  93. package/src/ai/task-execution.ts +15 -6
  94. package/src/ai/task-model.ts +9 -0
  95. package/src/ai/transcription.ts +27 -22
  96. package/src/ai/types.ts +50 -62
  97. package/src/ai/ui.tsx +13 -0
  98. package/src/ai/usage.ts +8 -8
  99. package/src/ai/validate.ts +8 -7
  100. package/src/ai/view-block.ts +2 -2
  101. package/src/api/accounts-entities.ts +16 -0
  102. package/src/api/admin-ai-quotas.ts +34 -56
  103. package/src/api/admin-ai-usage.ts +1 -1
  104. package/src/api/admin-ai.ts +173 -0
  105. package/src/api/admin-core-settings.ts +68 -244
  106. package/src/api/app-approval.ts +3 -1
  107. package/src/api/app-event-delivery.ts +51 -0
  108. package/src/api/app-events.ts +123 -0
  109. package/src/api/app-resource-access.ts +48 -0
  110. package/src/api/auth/account-mismatch.ts +24 -0
  111. package/src/api/auth.ts +26 -3
  112. package/src/api/capabilities.ts +28 -4
  113. package/src/api/index.ts +7 -1
  114. package/src/api/mcp.ts +175 -2
  115. package/src/api/me.ts +6 -4
  116. package/src/api/pwa-phone.ts +75 -26
  117. package/src/api/pwa.ts +34 -8
  118. package/src/api/resource-links.ts +83 -6
  119. package/src/api/resource-resolve.ts +50 -0
  120. package/src/api/search/schemas.ts +3 -1
  121. package/src/api/search.ts +9 -7
  122. package/src/app-pwa/contracts.ts +4 -0
  123. package/src/app-pwa/core-query-error.ts +20 -0
  124. package/src/app-pwa/index.ts +1 -0
  125. package/src/app-pwa/service.ts +91 -0
  126. package/src/app-pwa/transport.ts +22 -0
  127. package/src/approvals/messages.ts +2 -2
  128. package/src/browser/cloud-id.ts +86 -0
  129. package/src/browser/resource-clipboard.ts +4 -0
  130. package/src/browser/resource-links.ts +52 -0
  131. package/src/capabilities/server-invoke.ts +2 -11
  132. package/src/capabilities/testing.ts +5 -2
  133. package/src/cli/access.ts +6 -5
  134. package/src/cli/admin/ai-models.ts +199 -0
  135. package/src/cli/admin/ai-quotas.ts +6 -102
  136. package/src/cli/admin/ai-usage.ts +4 -4
  137. package/src/cli/admin/app-credentials.ts +5 -3
  138. package/src/cli/admin/index.ts +5 -2
  139. package/src/cli/apps.ts +89 -26
  140. package/src/cli/capabilities.ts +22 -1
  141. package/src/cli/events.ts +320 -0
  142. package/src/config/env.ts +40 -0
  143. package/src/contracts/app-access.ts +26 -2
  144. package/src/contracts/app-details.ts +89 -0
  145. package/src/contracts/app-events.ts +51 -0
  146. package/src/contracts/app.ts +21 -5
  147. package/src/contracts/capabilities.ts +143 -3
  148. package/src/contracts/index.ts +2 -0
  149. package/src/contracts/notification-types.ts +36 -9
  150. package/src/contracts/pwa.ts +17 -1
  151. package/src/contracts/registry.ts +6 -0
  152. package/src/contracts/resource-clipboard.ts +35 -0
  153. package/src/contracts/resource-pages.ts +140 -0
  154. package/src/contracts/resource-resolution.ts +13 -0
  155. package/src/contracts/shared.ts +4 -0
  156. package/src/contracts/user-notifications.ts +2 -0
  157. package/src/events/app-event-delivery.ts +100 -0
  158. package/src/events/app-events.testing.ts +65 -0
  159. package/src/events/app-events.ts +134 -0
  160. package/src/events/core-user-created.ts +24 -0
  161. package/src/events/index.ts +9 -0
  162. package/src/events/testing.ts +2 -0
  163. package/src/index.ts +1 -1
  164. package/src/server/api/respond.ts +8 -3
  165. package/src/server/index.ts +1 -0
  166. package/src/server/middleware/app-access.ts +1 -1
  167. package/src/server/middleware/auth.ts +21 -7
  168. package/src/server/middleware/index.ts +1 -0
  169. package/src/server/middleware/invocation.ts +2 -0
  170. package/src/server/public-storage.ts +106 -0
  171. package/src/server/services/access.ts +8 -0
  172. package/src/services/accounts/app.ts +6 -0
  173. package/src/services/accounts/entities.ts +14 -3
  174. package/src/services/accounts/groups.ts +3 -1
  175. package/src/services/accounts/identities.ts +62 -10
  176. package/src/services/accounts/users.ts +7 -6
  177. package/src/services/app-access.ts +25 -7
  178. package/src/services/app-details.ts +224 -0
  179. package/src/services/app-discovery.ts +2 -1
  180. package/src/services/app-event-shapes.ts +139 -0
  181. package/src/services/app-events/accept.ts +116 -0
  182. package/src/services/app-events/admin.ts +250 -0
  183. package/src/services/app-events/declarations.ts +187 -0
  184. package/src/services/app-events/deliver.ts +218 -0
  185. package/src/services/app-events.ts +92 -0
  186. package/src/services/config-conflict.ts +11 -0
  187. package/src/services/core-user-events.ts +1 -0
  188. package/src/services/freeipa-config.ts +9 -0
  189. package/src/services/geo/ledger.ts +28 -10
  190. package/src/services/geo/service.ts +43 -24
  191. package/src/services/identity/core-origin.ts +12 -0
  192. package/src/services/identity/invocation-actor.ts +20 -2
  193. package/src/services/identity/invocation-authority.ts +4 -0
  194. package/src/services/identity/invocation-token.ts +238 -56
  195. package/src/services/identity/issue-invocation.ts +6 -1
  196. package/src/services/image-metadata.ts +65 -1
  197. package/src/services/index.ts +13 -1
  198. package/src/services/ipa/sync.ts +6 -2
  199. package/src/services/ipa/users.ts +4 -1
  200. package/src/services/mandates/policy.ts +19 -12
  201. package/src/services/notifications/browser.ts +4 -0
  202. package/src/services/notifications/catalog.ts +5 -2
  203. package/src/services/notifications/platform.ts +7 -2
  204. package/src/services/notifications/user.ts +18 -7
  205. package/src/services/oauth-tokens.ts +28 -2
  206. package/src/services/object-store.ts +217 -0
  207. package/src/services/providers/local/users.ts +5 -0
  208. package/src/services/pwa-devices.ts +147 -37
  209. package/src/services/session/index.ts +14 -0
  210. package/src/services/settings/core-settings.ts +71 -43
  211. package/src/services/settings/index.ts +8 -0
  212. package/src/services/settings/versions.ts +26 -0
  213. package/src/shared/ai-models.ts +274 -0
  214. package/src/shared/ai-quotas.ts +2 -2
  215. package/src/shared/ai-usage.ts +2 -2
  216. package/src/shared/index.ts +2 -0
  217. package/src/shared-text/documents.ts +25 -8
  218. package/src/shared-text/log.ts +1 -1
  219. package/src/shared-text/text-diff.ts +75 -0
  220. package/src/ssr/AdminArea.tsx +131 -0
  221. package/src/ssr/AdminLayout.tsx +9 -3
  222. package/src/ssr/AdminSidebar.tsx +7 -7
  223. package/src/ssr/AppAccessDenied.tsx +2 -0
  224. package/src/ssr/AppBadge.tsx +14 -0
  225. package/src/ssr/AppLaunchpadPanel.tsx +1 -1
  226. package/src/ssr/CloudShell.island.tsx +25 -4
  227. package/src/ssr/Layout.tsx +8 -6
  228. package/src/ssr/PwaLayout.tsx +16 -1
  229. package/src/ssr/PwaPartCount.island.tsx +23 -0
  230. package/src/ssr/SettingsSaveOutcome.tsx +58 -0
  231. package/src/ssr/SettingsSaveStatus.island.tsx +16 -0
  232. package/src/ssr/__snapshots__/Layout.unchanged.render.test.tsx.snap +3 -3
  233. package/src/ssr/admin-active-link.ts +16 -0
  234. package/src/ssr/admin-navigation.ts +25 -20
  235. package/src/ssr/app-badges.ts +31 -7
  236. package/src/ssr/index.ts +8 -0
  237. package/src/ssr/page-trail-runtime.ts +3 -1
  238. package/src/ssr/platform-messages.ts +9 -1
  239. package/src/ssr/pwa-messages.ts +2 -2
  240. package/src/ssr/rail-navigation.ts +5 -1
  241. package/src/ssr/settings-draft.ts +153 -0
  242. package/src/ssr/shell-data.tsx +2 -0
  243. package/src/styles/global.css +1 -0
  244. package/src/styles/profile-picture.css +270 -0
  245. package/src/styles/utilities-navigation.css +6 -0
  246. package/src/workflows/ai/policy.ts +49 -0
  247. package/src/workflows/ai/runtime.ts +131 -21
  248. package/src/workflows/ai/store.ts +283 -35
  249. package/src/workflows/ai/types.ts +8 -2
  250. package/src/workflows/ai-actions.ts +148 -120
  251. package/src/workflows/authoring.ts +31 -0
  252. package/src/workflows/contracts.ts +1 -0
  253. package/src/workflows/definition.ts +2 -0
  254. package/src/workflows/editor.ts +15 -0
  255. package/src/workflows/index.ts +2 -1
  256. package/src/workflows/language/compiler.ts +34 -3
  257. package/src/workflows/runtime/executor.ts +132 -33
  258. package/src/workflows/runtime/index.ts +2 -0
  259. package/src/workflows/runtime/ports.ts +5 -1
  260. package/src/workflows/store/actions.ts +1 -1
  261. package/src/workflows/store/definitions.ts +5 -0
  262. package/src/workflows/store/runs.ts +5 -3
  263. package/src/workflows/store/worker.ts +7 -2
  264. package/src/ai/model-pricing.ts +0 -43
  265. package/src/ai/model-request-settings.ts +0 -98
  266. package/src/ai/settings-messages.ts +0 -129
  267. package/src/shared/ai-model-request-settings.ts +0 -21
@@ -0,0 +1,234 @@
1
+ import { sql } from "bun";
2
+ import { z } from "zod";
3
+ import { recursiveGroupIdsSubquery } from "../services/accounts/group-sql";
4
+ import { toPgTextArray } from "../services/postgres";
5
+ import { type AiDeleteModelInput, type AiModel, type AiModelSlot, type AiProvider, aiReplacementProblem } from "../shared/ai-models";
6
+ import type { WorkflowBoundPlan, WorkflowIrStep, WorkflowJsonValue } from "../workflows/contracts";
7
+ import { hashWorkflowSource } from "../workflows/language/canonical";
8
+ import { publishWorkflowVersion, type WorkflowActivationInput } from "../workflows/store/definitions";
9
+ import { aiModelAccess } from "./model-access";
10
+ import { AiConfigError, aiModelCatalog, assertAiRevision, lockAiModelCatalog, validateAiSlot } from "./model-catalog";
11
+
12
+ export const validateAiReplacement = (source: AiProvider, target: AiProvider, original: AiModel, replacement: AiModel): void => {
13
+ const problem = aiReplacementProblem(source, target, original, replacement);
14
+ if (problem === "private_boundary")
15
+ throw new AiConfigError("invalid_configuration", "A private model cannot be replaced with a hosted model.");
16
+ if (problem === "incompatible_model")
17
+ throw new AiConfigError(
18
+ "invalid_configuration",
19
+ "The replacement must be enabled and support the original model's kind and capabilities.",
20
+ );
21
+ };
22
+ const referencedAiModels = (value: unknown): string[] => {
23
+ if (Array.isArray(value)) return value.flatMap(referencedAiModels);
24
+ if (value !== null && typeof value === "object") {
25
+ const parsed = z.record(z.string(), z.unknown()).parse(value);
26
+ return Object.entries(parsed).flatMap(([key, entry]) =>
27
+ key === "model"
28
+ ? (() => {
29
+ const model = z.object({ id: z.string() }).safeParse(entry);
30
+ return model.success ? [model.data.id] : referencedAiModels(entry);
31
+ })()
32
+ : referencedAiModels(entry),
33
+ );
34
+ }
35
+ return [];
36
+ };
37
+ const retargetJson = (value: WorkflowJsonValue, ids: string[], replacement: string): WorkflowJsonValue => {
38
+ if (Array.isArray(value)) return value.map((entry) => retargetJson(entry, ids, replacement));
39
+ if (value !== null && typeof value === "object")
40
+ return Object.fromEntries(
41
+ Object.entries(value).map(([key, entry]) => [
42
+ key,
43
+ key === "model" &&
44
+ entry !== null &&
45
+ !Array.isArray(entry) &&
46
+ typeof entry === "object" &&
47
+ typeof entry.id === "string" &&
48
+ ids.includes(entry.id)
49
+ ? { id: replacement }
50
+ : retargetJson(entry, ids, replacement),
51
+ ]),
52
+ );
53
+ return value;
54
+ };
55
+ const retargetSteps = (steps: WorkflowIrStep[], ids: string[], replacement: string): WorkflowIrStep[] =>
56
+ steps.map((step) => {
57
+ if (step.kind === "action")
58
+ return {
59
+ ...step,
60
+ config: Object.fromEntries(
61
+ Object.entries(step.config).map(([key, value]) => [
62
+ key,
63
+ key === "model" &&
64
+ value !== null &&
65
+ !Array.isArray(value) &&
66
+ typeof value === "object" &&
67
+ typeof value.id === "string" &&
68
+ ids.includes(value.id)
69
+ ? { id: replacement }
70
+ : value,
71
+ ]),
72
+ ),
73
+ };
74
+ if (step.kind === "if")
75
+ return { ...step, then: retargetSteps(step.then, ids, replacement), else: retargetSteps(step.else, ids, replacement) };
76
+ if (step.kind === "switch")
77
+ return {
78
+ ...step,
79
+ cases: step.cases.map((entry) => ({ ...entry, steps: retargetSteps(entry.steps, ids, replacement) })),
80
+ default: retargetSteps(step.default, ids, replacement),
81
+ };
82
+ return { ...step, steps: retargetSteps(step.steps, ids, replacement) };
83
+ });
84
+ export const deleteAiCatalogEntry = async (
85
+ kind: "provider" | "model" | "variant",
86
+ id: string,
87
+ input: AiDeleteModelInput,
88
+ actorId: string,
89
+ ) =>
90
+ sql.begin(async (db) => {
91
+ // One catalog lock orders deletion with assignment and catalog writes.
92
+ await lockAiModelCatalog(db);
93
+ const current =
94
+ kind === "provider"
95
+ ? await aiModelCatalog.providerDetail(id, db)
96
+ : kind === "model"
97
+ ? await aiModelCatalog.modelDetail(id, db)
98
+ : await aiModelCatalog.variant(id, db, true);
99
+ await assertAiRevision(current, input.revision, db);
100
+ const allModels = await aiModelCatalog.models(db),
101
+ allVariants = await aiModelCatalog.variants(db),
102
+ providers = await aiModelCatalog.providers(db);
103
+ const affectedModels =
104
+ kind === "provider"
105
+ ? allModels.filter((model) => model.providerId === id)
106
+ : allModels.filter((model) => model.id === (kind === "model" ? id : allVariants.find((v) => v.id === id)?.modelId));
107
+ if (kind === "variant" && allVariants.find((v) => v.id === id)?.slug === "standard")
108
+ throw new AiConfigError("invalid_configuration", "The standard variant can only be deleted with its model.");
109
+ const affected = allVariants.filter((v) => (kind === "variant" ? v.id === id : affectedModels.some((m) => m.id === v.modelId)));
110
+ const ids = affected.map((v) => v.id),
111
+ pgIds = toPgTextArray(ids);
112
+ const target = (variantId: string, sourceModel: AiModel, slot?: AiModelSlot) => {
113
+ const variant = allVariants.find((v) => v.id === variantId && !ids.includes(v.id));
114
+ const model = allModels.find((m) => m.id === variant?.modelId),
115
+ provider = providers.find((p) => p.id === model?.providerId),
116
+ source = providers.find((p) => p.id === sourceModel.providerId);
117
+ if (!model || !provider || !source)
118
+ throw new AiConfigError("invalid_configuration", "Choose a replacement outside the deleted selection.");
119
+ validateAiReplacement(source, provider, sourceModel, model);
120
+ if (slot) validateAiSlot(slot, model);
121
+ };
122
+ const assignments = (await aiModelCatalog.assignments(db)).filter((a) => a.variantId && ids.includes(a.variantId));
123
+ for (const assignment of assignments) {
124
+ if (!Object.hasOwn(input.replacements, assignment.slot))
125
+ throw new AiConfigError("invalid_configuration", "Every affected assignment needs a replacement or an explicit empty choice.");
126
+ const replacement = input.replacements[assignment.slot];
127
+ const sourceModel = affectedModels.find((m) => m.id === affected.find((v) => v.id === assignment.variantId)?.modelId);
128
+ if (replacement && sourceModel) target(replacement, sourceModel, assignment.slot);
129
+ }
130
+ if (input.retarget && !input.retargetTo) throw new AiConfigError("invalid_configuration", "Retargeting requires a replacement.");
131
+ for (const assignment of assignments)
132
+ await db`UPDATE ai.model_assignments SET variant_id=${input.replacements[assignment.slot] ?? null},revision=revision+1,updated_by=${actorId}::uuid,updated_at=clock_timestamp() WHERE slot=${assignment.slot}`;
133
+ const projects = await db<
134
+ { id: string; short_id: string; name: string; default_model: { id: string } }[]
135
+ >`SELECT id,short_id,name,default_model FROM ai.projects WHERE default_model->>'id'=ANY(${pgIds}::text[]) FOR UPDATE`;
136
+ const workflows = await db<
137
+ {
138
+ id: string;
139
+ name: string;
140
+ created_by_kind: string;
141
+ created_by_id: string | null;
142
+ source: string;
143
+ plan: WorkflowBoundPlan;
144
+ effect_budget: Record<string, number>;
145
+ }[]
146
+ >`SELECT w.id,w.name,w.created_by_kind,w.created_by_id,v.source,v.plan,v.effect_budget FROM workflows.workflow w JOIN workflows.version v ON v.id=w.active_version_id WHERE EXISTS(SELECT 1 FROM jsonb_path_query(v.plan,'$.**.model.id') ref WHERE ref #>> '{}' = ANY(${pgIds}::text[])) FOR UPDATE OF w`;
147
+ if (input.retarget && input.retargetTo) {
148
+ const references = [
149
+ ...projects.map((project) => project.default_model.id),
150
+ ...workflows.flatMap((workflow) => referencedAiModels(workflow.plan)),
151
+ ];
152
+ for (const model of affectedModels.filter((model) =>
153
+ affected.some((variant) => variant.modelId === model.id && references.includes(variant.id)),
154
+ ))
155
+ target(input.retargetTo, model);
156
+ }
157
+ const scheduled = await db<
158
+ { sponsor_user_id: string; name: string }[]
159
+ >`SELECT task.sponsor_user_id,left(task.prompt,120) AS name FROM ai.chat_tasks task JOIN ai.conversations c ON c.id=task.conversation_id JOIN ai.projects p ON p.id=c.project_id WHERE p.default_model->>'id'=ANY(${pgIds}::text[])`;
160
+ for (const task of scheduled)
161
+ await db`INSERT INTO ai.model_change_notices(user_id,name,retargeted) VALUES(${task.sponsor_user_id}::uuid,${task.name},${input.retarget})`;
162
+ for (const project of projects) {
163
+ const owners = await db<
164
+ { id: string }[]
165
+ >`SELECT DISTINCT u.id FROM ai.project_access p JOIN auth.access a ON a.id=p.access_id JOIN auth.users u ON a.user_id=u.id OR a.authenticated_only OR a.group_id IN (${recursiveGroupIdsSubquery(db`u.id`)}) WHERE p.project_id=${project.id}::uuid AND a.permission='admin'`;
166
+ for (const owner of owners)
167
+ await db`INSERT INTO ai.model_change_notices(user_id,name,retargeted) VALUES(${owner.id}::uuid,${project.name},${input.retarget})`;
168
+ if (input.retarget)
169
+ await db`UPDATE ai.projects SET default_model=${JSON.stringify({ id: input.retargetTo })}::text::jsonb,revision=revision+1,updated_at=clock_timestamp() WHERE id=${project.id}::uuid`;
170
+ }
171
+ for (const workflow of workflows) {
172
+ if (workflow.created_by_kind === "user" && workflow.created_by_id)
173
+ await db`INSERT INTO ai.model_change_notices(user_id,name,retargeted) VALUES(${workflow.created_by_id}::uuid,${workflow.name},${input.retarget})`;
174
+ if (!input.retarget || !input.retargetTo) continue;
175
+ const activations = await db<
176
+ (WorkflowActivationInput & { event_type: string; authorization_snapshot: WorkflowJsonValue })[]
177
+ >`SELECT key,event_type,config,enabled,authorization_snapshot FROM workflows.activation WHERE workflow_id=${workflow.id}::uuid`;
178
+ const parsed: WorkflowJsonValue = z.json().parse(Bun.YAML.parse(workflow.source));
179
+ const source = Bun.YAML.stringify(retargetJson(parsed, ids, input.retargetTo));
180
+ const plan: WorkflowBoundPlan = {
181
+ ...workflow.plan,
182
+ sourceHash: await hashWorkflowSource(source),
183
+ steps: retargetSteps(workflow.plan.steps, ids, input.retargetTo),
184
+ bindings: Object.fromEntries(
185
+ Object.entries(workflow.plan.bindings).map(([key, value]) => [
186
+ key,
187
+ key === "model" &&
188
+ value !== null &&
189
+ !Array.isArray(value) &&
190
+ typeof value === "object" &&
191
+ typeof value.id === "string" &&
192
+ ids.includes(value.id)
193
+ ? { id: input.retargetTo! }
194
+ : retargetJson(value, ids, input.retargetTo!),
195
+ ]),
196
+ ),
197
+ };
198
+ await publishWorkflowVersion(
199
+ {
200
+ workflowId: workflow.id,
201
+ source,
202
+ plan,
203
+ effectBudget: workflow.effect_budget,
204
+ author: { kind: "user", id: actorId },
205
+ activations: activations.map((a) => ({ ...a, eventType: a.event_type })),
206
+ },
207
+ { db },
208
+ );
209
+ for (const activation of activations)
210
+ await db`UPDATE workflows.activation SET authorization_snapshot=${JSON.stringify(activation.authorization_snapshot)}::text::jsonb WHERE workflow_id=${workflow.id}::uuid AND key=${activation.key}`;
211
+ }
212
+ if (kind === "variant") await db`DELETE FROM ai.model_variants WHERE id=${id}`;
213
+ else if (kind === "model") await db`DELETE FROM ai.models WHERE id=${id}`;
214
+ else await db`DELETE FROM ai.providers WHERE id=${id}`;
215
+ if (kind !== "variant") {
216
+ const modelIds = affectedModels.map((m) => m.id);
217
+ const [config] = await db<{ rules: WorkflowJsonValue[] }[]>`SELECT rules FROM ai.cost_config WHERE singleton FOR UPDATE`;
218
+ const rules = (config?.rules ?? []).filter(
219
+ (rule) =>
220
+ rule === null ||
221
+ Array.isArray(rule) ||
222
+ typeof rule !== "object" ||
223
+ typeof rule.scope !== "string" ||
224
+ !modelIds.includes(rule.scope),
225
+ );
226
+ await db`UPDATE ai.cost_config SET rules=${JSON.stringify(rules)}::text::jsonb,revision=revision+1 WHERE singleton`;
227
+ await aiModelAccess.syncModels(
228
+ (await aiModelCatalog.models(db)).map((model) => model.id),
229
+ [],
230
+ db,
231
+ );
232
+ }
233
+ return { deleted: true };
234
+ });
@@ -0,0 +1,39 @@
1
+ import type { AiModelAssignment, AiModelSlot } from "../shared/ai-models";
2
+ import type { AiModelProfile, AiSettingsState } from "./types";
3
+
4
+ /** Fixed snapshots for tests; production reads the catalog. */
5
+ export const aiModelFixture = (overrides: Partial<AiModelProfile> = {}): AiModelProfile => ({
6
+ id: "chat",
7
+ modelId: "chat",
8
+ providerId: "provider",
9
+ label: "Chat",
10
+ variantLabel: "Standard",
11
+ description: "For everyday work",
12
+ icon: { kind: "provider" },
13
+ showInPicker: true,
14
+ provider: "ollama",
15
+ model: "chat",
16
+ enabled: true,
17
+ capabilities: ["tools"],
18
+ dataBoundary: "private",
19
+ ...overrides,
20
+ });
21
+ export const aiAssignmentFixture = (slot: AiModelSlot, variantId: string | null): AiModelAssignment => ({
22
+ slot,
23
+ variantId,
24
+ revision: 1,
25
+ updatedAt: "2026-10-16T00:00:00.000Z",
26
+ updatedBy: null,
27
+ });
28
+ export const aiSettingsFixture = (overrides: Partial<AiSettingsState> = {}): AiSettingsState => ({
29
+ ok: true,
30
+ enabled: true,
31
+ profiles: [aiModelFixture()],
32
+ assignments: [aiAssignmentFixture("balanced", "chat")],
33
+ globalInstructions: "",
34
+ localClientInstructions: "",
35
+ compactionInstructions: "",
36
+ maxToolResultChars: 2_000_000,
37
+ firecrawlConfigured: false,
38
+ ...overrides,
39
+ });
@@ -0,0 +1,36 @@
1
+ import type { JSX } from "solid-js";
2
+ import { type AiModelIcon as AiModelIconSetting, type AiPeopleModelIcon, aiProviderMark } from "../shared/ai-models";
3
+
4
+ /** The icon a person sees for a model: its emoji or image, else its provider's neutral mark. */
5
+ export const aiModelDisplayIcon = (icon: AiModelIconSetting, provider: { id: string; label: string }): AiPeopleModelIcon =>
6
+ icon.kind === "image"
7
+ ? { kind: "image", src: icon.dataUrl }
8
+ : icon.kind === "emoji"
9
+ ? icon
10
+ : { kind: "mark", ...aiProviderMark(provider) };
11
+
12
+ const sizes = { xs: "size-5 rounded-[5px] text-[9px]", sm: "size-7 rounded-md text-[10px]", md: "size-10 rounded-lg text-xs" } as const;
13
+
14
+ /**
15
+ * A model's icon, the one mark that tells models apart in a chooser. It is decoration: the model's name always sits
16
+ * next to it, so it stays hidden from assistive technology.
17
+ */
18
+ export function AiModelIcon(props: { icon: AiPeopleModelIcon; size?: keyof typeof sizes; class?: string }): JSX.Element {
19
+ const box = () =>
20
+ `inline-grid shrink-0 place-items-center overflow-hidden leading-none ${sizes[props.size ?? "xs"]} ${props.class ?? ""}`;
21
+ const icon = () => props.icon;
22
+ return (
23
+ <span class={box()} aria-hidden="true" data-ai-model-icon={icon().kind}>
24
+ {(() => {
25
+ const current = icon();
26
+ if (current.kind === "image") return <img src={current.src} alt="" class="size-full object-cover" />;
27
+ if (current.kind === "emoji") return <span class="text-[1.15em]">{current.emoji}</span>;
28
+ return (
29
+ <span class="grid size-full place-items-center font-semibold tracking-wide text-white" style={{ background: current.color }}>
30
+ {current.text}
31
+ </span>
32
+ );
33
+ })()}
34
+ </span>
35
+ );
36
+ }
@@ -0,0 +1,53 @@
1
+ import { coreSettings } from "../services";
2
+ import { AI_MODEL_CLASSES, type AiModelClass, type AiModelSlot, type AiProvider } from "../shared/ai-models";
3
+ import { aiModelCatalog } from "./model-catalog";
4
+ import { resolveAiModelRef } from "./settings";
5
+
6
+ export type AiAdminOverview = {
7
+ enabled: boolean;
8
+ providers: (Pick<
9
+ AiProvider,
10
+ "id" | "type" | "label" | "enabled" | "status" | "statusCode" | "statusMessage" | "statusCheckedAt" | "logo" | "credentialSet"
11
+ > & {
12
+ modelCount: number;
13
+ })[];
14
+ assignments: Record<AiModelSlot, { variantId: string | null; revision: number; updatedAt: string; updatedBy: string | null }>;
15
+ effective: Record<AiModelClass, { class: AiModelClass | null; variantId: string | null }>;
16
+ };
17
+
18
+ /** What the AI area's first column and the class rows need, read once for the page and the API. */
19
+ export const readAiAdminOverview = async (): Promise<AiAdminOverview> => {
20
+ const [enabled, providers, models, assignments] = await Promise.all([
21
+ coreSettings.get<boolean>("ai.enabled"),
22
+ aiModelCatalog.providers(),
23
+ aiModelCatalog.models(),
24
+ aiModelCatalog.assignments(),
25
+ ]);
26
+ return {
27
+ enabled,
28
+ providers: providers.map((p) => ({
29
+ id: p.id,
30
+ type: p.type,
31
+ label: p.label,
32
+ enabled: p.enabled,
33
+ status: p.status,
34
+ statusCode: p.statusCode,
35
+ statusMessage: p.statusMessage,
36
+ statusCheckedAt: p.statusCheckedAt,
37
+ credentialSet: p.credentialSet,
38
+ modelCount: models.filter((m) => m.providerId === p.id).length,
39
+ logo: p.logo,
40
+ })),
41
+ assignments: Object.fromEntries(assignments.map(({ slot, ...assignment }) => [slot, assignment])) as AiAdminOverview["assignments"],
42
+ effective: Object.fromEntries(
43
+ AI_MODEL_CLASSES.map((modelClass) => {
44
+ try {
45
+ const ref = resolveAiModelRef({ enabled: true, assignments }, { class: modelClass });
46
+ return [modelClass, { class: ref.class ?? null, variantId: ref.id }];
47
+ } catch {
48
+ return [modelClass, { class: null, variantId: null }];
49
+ }
50
+ }),
51
+ ) as AiAdminOverview["effective"],
52
+ };
53
+ };
@@ -0,0 +1,45 @@
1
+ import { sql } from "bun";
2
+ import { toPgTextArray } from "../services/postgres";
3
+ import type { AiModelUsage } from "../shared/ai-models";
4
+ import { aiModelCatalog } from "./model-catalog";
5
+ import { aiPeopleAtLimit } from "./quota-limits";
6
+ import { aiQuotas } from "./quotas";
7
+
8
+ export const aiModelUsage = async (variantIds: string[], modelIds: string[], db = sql): Promise<AiModelUsage> => {
9
+ const ids = toPgTextArray(variantIds);
10
+ const [assignments, projects, workflows, scheduledTasks, counts, stats, config, people] = await Promise.all([
11
+ aiModelCatalog.assignments(db),
12
+ db<
13
+ { id: string; name: string }[]
14
+ >`SELECT short_id AS id,name FROM ai.projects WHERE default_model->>'id'=ANY(${ids}::text[]) ORDER BY name,id`,
15
+ db<
16
+ { id: string; name: string }[]
17
+ >`SELECT w.id,w.name FROM workflows.workflow w JOIN workflows.version v ON v.id=w.active_version_id WHERE EXISTS (SELECT 1 FROM jsonb_path_query(v.plan,'$.**.model.id') ref WHERE ref #>> '{}' = ANY(${ids}::text[])) ORDER BY w.name,w.id`,
18
+ db<
19
+ { id: string; name: string }[]
20
+ >`SELECT task.short_id AS id,left(task.prompt,120) AS name FROM ai.chat_tasks task JOIN ai.conversations c ON c.id=task.conversation_id LEFT JOIN ai.projects p ON p.id=c.project_id WHERE p.default_model->>'id'=ANY(${ids}::text[]) ORDER BY task.id`,
21
+ db<{ chats: number }[]>`SELECT count(DISTINCT conversation_id)::int AS chats FROM ai.turns WHERE model_profile_id=ANY(${ids}::text[])`,
22
+ db<AiModelUsage["stats"][]>`WITH answers AS (
23
+ SELECT COALESCE(turn_id,id) AS answer, CASE WHEN bool_and(cost IS NOT NULL) THEN sum(cost) END AS cost,
24
+ extract(epoch FROM max(finished_at)-min(started_at)) AS seconds
25
+ FROM ai.inference_calls WHERE model_profile_id=ANY(${ids}::text[]) AND started_at>=now()-interval '14 days'
26
+ AND kind='chat' AND status='ok' GROUP BY COALESCE(turn_id,id)
27
+ ) SELECT count(*)::int AS "answers14d", CASE WHEN count(*)>=20 THEN avg(cost)::float8 END AS "avgCostPerAnswer", CASE WHEN count(*)>=20 THEN avg(seconds)::float8 END AS "avgSecondsPerAnswer" FROM answers`,
28
+ aiQuotas.config(db),
29
+ // Count exhausted people per rule, then deduplicate global and model scopes.
30
+ aiPeopleAtLimit(db),
31
+ ]);
32
+ return {
33
+ assignments: assignments
34
+ .filter((assignment) => assignment.variantId && variantIds.includes(assignment.variantId))
35
+ .map((assignment) => assignment.slot),
36
+ projects,
37
+ workflows,
38
+ scheduledTasks,
39
+ chats: counts[0]?.chats ?? 0,
40
+ budgetRules: config.rules.filter((rule) => modelIds.includes(rule.scope)).map((rule) => ({ id: rule.scope, label: rule.scope })),
41
+ peopleAtLimit: new Set(people.filter((rule) => rule.scope === "*" || modelIds.includes(rule.scope)).flatMap((rule) => rule.people))
42
+ .size,
43
+ stats: stats[0] ?? { answers14d: 0, avgCostPerAnswer: null, avgSecondsPerAnswer: null },
44
+ };
45
+ };
@@ -14,8 +14,6 @@ const catalog = i18n.define({
14
14
  context_length_exceeded: "Your messages exceed this model's context window. Shorten them and try again.",
15
15
  request_too_large: "The request body exceeds the limit derived from your available models' context windows.",
16
16
  ai_disabled: "AI is turned off. Contact an administrator.",
17
- model_access_denied: "You do not have access to this model. Contact an administrator.",
18
- missing_provider_credential: "This model's provider is not configured. Contact an administrator.",
19
17
  insufficient_quota:
20
18
  "Your Assistant usage limit is reached, or your usage could not be measured. Wait for the reset or contact an administrator.",
21
19
  too_many_concurrent_requests: "You have too many simultaneous requests. Wait for a request to finish and try again.",
@@ -35,8 +33,6 @@ const catalog = i18n.define({
35
33
  context_length_exceeded: "Deine Nachrichten überschreiten das Kontextfenster dieses Modells. Kürze sie und versuche es erneut.",
36
34
  request_too_large: "Deine Anfrage überschreitet die aus den Kontextfenstern deiner verfügbaren Modelle abgeleitete Größenbegrenzung.",
37
35
  ai_disabled: "KI ist ausgeschaltet. Wende dich an einen Administrator.",
38
- model_access_denied: "Dieses Modell ist nicht für dich freigegeben. Wende dich an einen Administrator.",
39
- missing_provider_credential: "Der Anbieter dieses Modells ist nicht eingerichtet. Wende dich an einen Administrator.",
40
36
  insufficient_quota:
41
37
  "Dein Nutzungslimit im Assistenten ist erreicht, oder deine Nutzung konnte nicht gemessen werden. Warte auf das Zurücksetzen oder wende dich an einen Administrator.",
42
38
  too_many_concurrent_requests: "Du hast zu viele gleichzeitige Anfragen. Warte, bis eine Anfrage beendet ist, und versuche es erneut.",
@@ -9,8 +9,10 @@ import { type AuthContext, getLocale, rateLimit } from "../server";
9
9
  import { REQUEST_IDLE_TIMEOUT_SECONDS, setRequestTimeout } from "../server/request-timeout";
10
10
  import { authenticateModelsOnlyCredential } from "../services/identity/models-only-auth";
11
11
  import { coreSettings } from "../services/settings/api";
12
+ import { AI_MODEL_CLASSES, AiModelRequestSchema } from "../shared/ai-models";
12
13
  import { listAssistantAiModels } from "./assistant-models";
13
14
  import { aiModelAccess } from "./model-access";
15
+ import { aiModelCatalog } from "./model-catalog";
14
16
  import { type OpenAiErrorCode, openAiApiMessages } from "./openai-api-messages";
15
17
  import { acquire } from "./openai-api-slots";
16
18
  import { personalAiModelPolicy } from "./personal-agent";
@@ -78,6 +80,7 @@ type Dependencies = {
78
80
  settings?: Pick<typeof coreSettings, "get">;
79
81
  authenticate?: typeof authenticateModelsOnlyCredential;
80
82
  listModels?: typeof listAssistantAiModels;
83
+ assignments?: typeof aiModelCatalog.assignments;
81
84
  resolveModel?: typeof resolveAiModel;
82
85
  assertModelAccess?: typeof aiModelAccess.assertAllowed;
83
86
  acquire?: typeof acquire;
@@ -89,6 +92,7 @@ export const createAiOpenAiRoutes = (dependencies: Dependencies = {}) => {
89
92
  const settings = dependencies.settings ?? coreSettings;
90
93
  const authenticate = dependencies.authenticate ?? authenticateModelsOnlyCredential;
91
94
  const listModels = dependencies.listModels ?? listAssistantAiModels;
95
+ const assignments = dependencies.assignments ?? (() => aiModelCatalog.assignments());
92
96
  const resolveModel = dependencies.resolveModel ?? resolveAiModel;
93
97
  const assertModelAccess = dependencies.assertModelAccess ?? aiModelAccess.assertAllowed;
94
98
  const acquireSlot = dependencies.acquire ?? acquire;
@@ -103,9 +107,8 @@ export const createAiOpenAiRoutes = (dependencies: Dependencies = {}) => {
103
107
  const parsed = z.object({ code: z.string() }).safeParse(error.aiError);
104
108
  if (parsed.success) {
105
109
  if (parsed.data.code === "ai_disabled") return errorResponse(c, 403, "ai_disabled", "permission_error");
106
- if (parsed.data.code === "model_access_denied") return errorResponse(c, 403, "model_access_denied", "permission_error", "model");
107
- if (parsed.data.code === "model_policy_mismatch") return errorResponse(c, 404, "model_not_found", "invalid_request_error", "model");
108
- if (parsed.data.code === "missing_provider_credential") return errorResponse(c, 502, "missing_provider_credential", "api_error");
110
+ if (parsed.data.code === "model_policy_mismatch" || parsed.data.code === "ai_no_model")
111
+ return errorResponse(c, 404, "model_not_found", "invalid_request_error", "model");
109
112
  }
110
113
  }
111
114
  return errorResponse(c, 500, "internal_error", "api_error");
@@ -136,9 +139,16 @@ export const createAiOpenAiRoutes = (dependencies: Dependencies = {}) => {
136
139
  const models = await listModels(c.get("accessSubject"));
137
140
  return c.json({
138
141
  object: "list",
139
- data: models
140
- .filter((model) => !model.capabilities.includes("transcription"))
141
- .map((model) => ({ id: model.id, object: "model", created: 0, owned_by: "cloud" })),
142
+ data: [
143
+ ...models.filter((model) => !model.capabilities.includes("transcription")).map((model) => model.id),
144
+ ...(await assignments()).flatMap((assignment) =>
145
+ AI_MODEL_CLASSES.some((cls) => cls === assignment.slot) &&
146
+ assignment.variantId &&
147
+ models.some((model) => model.id === assignment.variantId)
148
+ ? [`cloud/${assignment.slot}`]
149
+ : [],
150
+ ),
151
+ ].map((id) => ({ id, object: "model", created: 0, owned_by: "cloud" })),
142
152
  });
143
153
  },
144
154
  )
@@ -204,9 +214,15 @@ export const createAiOpenAiRoutes = (dependencies: Dependencies = {}) => {
204
214
  else if (message.role === "user") messages.push({ role: "user", content: [text] });
205
215
  else messages.push({ role: "assistant", content: [{ type: "text", text }] });
206
216
  }
207
- if (!models.some((model) => model.id === data.model))
217
+ if (
218
+ !models.some((model) => model.id === data.model) &&
219
+ !(data.model.startsWith("cloud/") && AI_MODEL_CLASSES.some((cls) => `cloud/${cls}` === data.model))
220
+ )
208
221
  return errorResponse(c, 404, "model_not_found", "invalid_request_error", "model");
209
- const resolved = await resolveModel(personalAiModelPolicy, data.model);
222
+ const resolved = await resolveModel(
223
+ personalAiModelPolicy,
224
+ AiModelRequestSchema.parse(data.model.startsWith("cloud/") ? { class: data.model.slice(6) } : { id: data.model }),
225
+ );
210
226
  const systemPrompt = system.join("\n\n");
211
227
  const inputTokens = estimateTokens(messages) + Math.ceil(systemPrompt.length / 4);
212
228
  if (resolved.provider.contextWindow !== undefined && inputTokens > resolved.provider.contextWindow)
@@ -248,7 +264,7 @@ export const createAiOpenAiRoutes = (dependencies: Dependencies = {}) => {
248
264
  JSON.stringify(current.subject) !== JSON.stringify(subject)
249
265
  )
250
266
  throw new ModelsCredentialRevoked();
251
- await assertModelAccess(resolved.profile.id, subject);
267
+ await assertModelAccess(resolved.profile.modelId, subject);
252
268
  } catch (error) {
253
269
  if (error instanceof ModelsCredentialRevoked) throw error;
254
270
  if (error instanceof Error && "aiError" in error)
@@ -271,6 +287,7 @@ export const createAiOpenAiRoutes = (dependencies: Dependencies = {}) => {
271
287
  };
272
288
  const provider: Provider = wrapProvider(guarded, resolved.profile, {
273
289
  kind: "chat",
290
+ modelRef: resolved.model,
274
291
  task: "openai-api",
275
292
  subject,
276
293
  credentialId: credential.credentialId,
@@ -2,5 +2,5 @@ import type { AiModelPolicy } from "./types";
2
2
 
3
3
  export const personalAiModelPolicy: AiModelPolicy = {
4
4
  kind: "selectable",
5
- requiredCapabilities: ["streaming", "tools"],
5
+ requiredCapabilities: ["tools"],
6
6
  };
package/src/ai/prefs.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { sql } from "bun";
2
2
  import type { RequestActor } from "../server";
3
+ import { type AiModelRequest, AiModelRequestSchema } from "../shared/ai-models";
3
4
 
4
5
  /**
5
6
  * Learning from private chats applies to everyone who has not chosen.
@@ -12,8 +13,8 @@ export type AiUserPrefs = {
12
13
  memoryEnabled: boolean;
13
14
  /** Effective setting: the user's explicit choice, otherwise the default. */
14
15
  memoryLearningEnabled: boolean;
15
- /** Model profile id of the user's most recent chat turn — preselected for new chats. */
16
- lastModelId: string;
16
+ /** Class or explicit variant choice, preselected for new chats. */
17
+ selection: AiModelRequest | null;
17
18
  updatedAt: string;
18
19
  };
19
20
 
@@ -21,7 +22,7 @@ type PrefsRow = {
21
22
  user_id: string;
22
23
  memory_enabled: boolean;
23
24
  memory_learning_enabled: boolean | null;
24
- last_model_id: string | null;
25
+ model_choice: unknown;
25
26
  updated_at: string | Date;
26
27
  };
27
28
 
@@ -29,7 +30,7 @@ const toPrefs = (row: PrefsRow): AiUserPrefs => ({
29
30
  userId: row.user_id,
30
31
  memoryEnabled: row.memory_enabled,
31
32
  memoryLearningEnabled: row.memory_learning_enabled ?? AI_MEMORY_LEARNING_DEFAULT_ENABLED,
32
- lastModelId: row.last_model_id ?? "",
33
+ selection: row.model_choice ? AiModelRequestSchema.parse(row.model_choice) : null,
33
34
  updatedAt: new Date(row.updated_at).toISOString(),
34
35
  });
35
36
 
@@ -37,7 +38,7 @@ const emptyPrefs = (userId: string): AiUserPrefs => ({
37
38
  userId,
38
39
  memoryEnabled: true,
39
40
  memoryLearningEnabled: AI_MEMORY_LEARNING_DEFAULT_ENABLED,
40
- lastModelId: "",
41
+ selection: null,
41
42
  updatedAt: new Date(0).toISOString(),
42
43
  });
43
44
 
@@ -59,25 +60,26 @@ export const aiUserPrefs = {
59
60
 
60
61
  async update(
61
62
  userId: string,
62
- patch: { memoryEnabled?: boolean; memoryLearningEnabled?: boolean; lastModelId?: string },
63
+ patch: { memoryEnabled?: boolean; memoryLearningEnabled?: boolean; selection?: AiModelRequest | null },
63
64
  ): Promise<AiUserPrefs> {
64
65
  const memoryEnabled = patch.memoryEnabled ?? null;
65
66
  const memoryLearningEnabled = patch.memoryLearningEnabled ?? null;
66
67
  // Records when the person last saved the learning switch, so a stored "off"
67
68
  // from an earlier release stays distinguishable from a choice made since.
68
69
  const learningChosen = memoryLearningEnabled !== null;
69
- const lastModelId = patch.lastModelId?.trim().slice(0, 200) ?? null;
70
+ const choiceChanged = Object.hasOwn(patch, "selection");
71
+ const modelChoice = patch.selection ? JSON.stringify(AiModelRequestSchema.parse(patch.selection)) : null;
70
72
  const rows = (await sql`
71
- INSERT INTO ai.user_prefs (user_id, memory_enabled, memory_learning_enabled, memory_learning_chosen_at, last_model_id, updated_at)
73
+ INSERT INTO ai.user_prefs (user_id, memory_enabled, memory_learning_enabled, memory_learning_chosen_at, model_choice, updated_at)
72
74
  VALUES (
73
75
  ${userId}, ${memoryEnabled ?? true}, ${memoryLearningEnabled},
74
- CASE WHEN ${learningChosen}::boolean THEN now() END, ${lastModelId ?? ""}, now()
76
+ CASE WHEN ${learningChosen}::boolean THEN now() END, ${modelChoice}::text::jsonb, now()
75
77
  )
76
78
  ON CONFLICT (user_id) DO UPDATE SET
77
79
  memory_enabled = COALESCE(${memoryEnabled}, ai.user_prefs.memory_enabled),
78
80
  memory_learning_enabled = COALESCE(${memoryLearningEnabled}, ai.user_prefs.memory_learning_enabled),
79
81
  memory_learning_chosen_at = CASE WHEN ${learningChosen}::boolean THEN now() ELSE ai.user_prefs.memory_learning_chosen_at END,
80
- last_model_id = COALESCE(${lastModelId}, ai.user_prefs.last_model_id),
82
+ model_choice = CASE WHEN ${choiceChanged} THEN ${modelChoice}::text::jsonb ELSE ai.user_prefs.model_choice END,
81
83
  updated_at = now()
82
84
  RETURNING *
83
85
  `) as PrefsRow[];
@@ -6,6 +6,7 @@ import { AuthenticatedPrincipalSchema } from "../contracts/shared";
6
6
  import { type AuthContext, auth, err, fail, getLocale, ok, rateLimit, respond, v } from "../server";
7
7
  import { noTransform } from "../server/middleware/no-transform";
8
8
  import { ImageMetadataError } from "../services/image-metadata";
9
+ import { AiModelRequestSchema } from "../shared/ai-models";
9
10
  import { aiChatMessages } from "./chat/messages";
10
11
  import { decodeAiFileContent } from "./files-store";
11
12
  import {
@@ -27,7 +28,7 @@ const ProjectFieldsSchema = z.object({
27
28
  description: z.string().trim().max(AI_PROJECT_DESCRIPTION_MAX_CHARS).default(""),
28
29
  icon: z.string().trim().min(1).max(80).default("ti ti-folders"),
29
30
  instructions: z.string().trim().max(AI_PROJECT_INSTRUCTIONS_MAX_CHARS).default(""),
30
- defaultModelProfileId: z.string().trim().min(1).max(120).nullable().optional(),
31
+ defaultModel: AiModelRequestSchema.nullable().optional(),
31
32
  });
32
33
 
33
34
  const UpdateProjectSchema = ProjectFieldsSchema.partial().refine((input) => Object.keys(input).length > 0, "No changes supplied.");