@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
@@ -4,18 +4,21 @@ import { recursiveGroupIdsSubquery } from "../services/accounts/group-sql";
4
4
  import { toPgTextArray } from "../services/postgres";
5
5
  import { hasBillableAiPricing } from "../shared/ai-costs";
6
6
  import { type AiQuotaReport, type AiQuotaReportQuery, AiQuotaReportQuerySchema } from "../shared/ai-quotas";
7
+ import { aiPeopleAtLimit } from "./quota-limits";
7
8
  import { aiQuotas } from "./quotas";
8
9
  import { readAiSettingsState } from "./settings";
9
10
 
10
11
  /** Resolve display metadata only for administration, never on the inference path. */
11
12
  export async function quotaAdminConfig() {
12
13
  const config = await aiQuotas.config();
14
+ const limits = await aiPeopleAtLimit();
13
15
  const grants = await resolveDisplayNames(config.rules.flatMap((rule) => rule.grants));
14
16
  let offset = 0;
15
17
  return {
16
18
  ...config,
17
19
  rules: config.rules.map((rule) => ({
18
20
  ...rule,
21
+ peopleAtLimit: limits.find((limit) => limit.scope === rule.scope)?.people.length ?? 0,
19
22
  grants: rule.grants.map(() => {
20
23
  const grant = grants[offset++]!;
21
24
  return {
@@ -50,7 +53,7 @@ export async function quotaReport(input: Partial<AiQuotaReportQuery> = {}, now =
50
53
  page: 1,
51
54
  perPage: 25,
52
55
  };
53
- const pricedModels = (await readAiSettingsState()).profiles.filter((p) => hasBillableAiPricing(p.pricing)).map((p) => p.id);
56
+ const pricedModels = (await readAiSettingsState()).profiles.filter((p) => hasBillableAiPricing(p.pricing)).map((p) => p.modelId);
54
57
  const bucket = query.range === "24h" ? "hour" : "day";
55
58
  const order = query.sort === "label" ? sql`label` : query.sort === "cost" ? sql`cost` : sql`last_used`;
56
59
  const direction = query.direction === "asc" ? sql`ASC` : sql`DESC`;
@@ -80,7 +83,7 @@ export async function quotaReport(input: Partial<AiQuotaReportQuery> = {}, now =
80
83
  FROM ai.inference_calls c WHERE c.kind='chat' AND c.started_at<=${now}
81
84
  ), periods AS MATERIALIZED (
82
85
  SELECT * FROM calls WHERE started_at>=${since} AND started_at<${until}
83
- AND (${query.model}='' OR model_profile_id=${query.model})
86
+ AND (${query.model}='' OR model_id=${query.model})
84
87
  ), allowances AS MATERIALIZED (
85
88
  SELECT a.id,a.type,r.scope,
86
89
  CASE WHEN bool_or((g->>'limit') IS NULL) FILTER(WHERE g IS NOT NULL) THEN NULL ELSE COALESCE(max((g->>'limit')::float8),0) END AS amount,
@@ -95,7 +98,7 @@ export async function quotaReport(input: Partial<AiQuotaReportQuery> = {}, now =
95
98
  ), balances AS MATERIALIZED (
96
99
  SELECT a.*,COALESCE(sum(c.cost),0)::float8 AS used,count(c.id) FILTER(WHERE c.unknown)::int AS unknown
97
100
  FROM allowances a LEFT JOIN calls c ON c.identity_id=a.id AND c.identity_type=a.type
98
- AND (a.scope='*' OR c.model_profile_id=a.scope) AND c.started_at>=a.starts
101
+ AND (a.scope='*' OR c.model_id=a.scope) AND c.started_at>=a.starts
99
102
  AND c.started_at>COALESCE((SELECT max(created_at) FROM ai.cost_resets z
100
103
  WHERE COALESCE(z.user_id,z.service_account_id)=a.id AND (z.user_id IS NOT NULL)=(a.type='user') AND z.scope=a.scope),'-infinity'::timestamptz)
101
104
  GROUP BY a.id,a.type,a.scope,a.amount,a.starts
@@ -111,7 +114,7 @@ export async function quotaReport(input: Partial<AiQuotaReportQuery> = {}, now =
111
114
  SELECT a.id,a.type,a.label,
112
115
  CASE WHEN NOT COALESCE((SELECT enabled FROM config),false) THEN 'disabled' ELSE COALESCE(s.status,'unlimited') END AS status,
113
116
  COALESCE(s.scopes,0) AS scopes,CASE WHEN s.status='unlimited' OR NOT COALESCE((SELECT enabled FROM config),false) THEN 0 ELSE COALESCE(s.exhausted,0) END AS exhausted,
114
- (SELECT max(started_at) FROM calls c WHERE c.identity_id=a.id AND c.identity_type=a.type AND (${query.model}='' OR c.model_profile_id=${query.model})) AS last_used,
117
+ (SELECT max(started_at) FROM calls c WHERE c.identity_id=a.id AND c.identity_type=a.type AND (${query.model}='' OR c.model_id=${query.model})) AS last_used,
115
118
  sum(p.cost)::float8 AS cost,COALESCE(sum(p.input),0)::float8 AS input,COALESCE(sum(p.output),0)::float8 AS output,
116
119
  count(p.id)::int AS calls,count(p.id) FILTER(WHERE p.input IS NOT NULL AND p.output IS NOT NULL)::int AS measured,count(p.id) FILTER(WHERE p.estimated)::int AS estimated,count(p.id) FILTER(WHERE p.unknown)::int AS unknown
117
120
  FROM identities a LEFT JOIN states s ON s.id=a.id AND s.type=a.type
@@ -23,6 +23,7 @@ export async function migrateAiQuotas() {
23
23
  CHECK(cost IS NULL OR cost>=0), CHECK(reserved>=0),
24
24
  CHECK(input IS NULL OR input>=0), CHECK(output IS NULL OR output>=0)
25
25
  )`;
26
+ await db`ALTER TABLE ai.inference_calls ADD COLUMN IF NOT EXISTS credential_id UUID`;
26
27
  await db`ALTER TABLE ai.inference_calls ADD COLUMN IF NOT EXISTS estimated BOOLEAN NOT NULL DEFAULT false`;
27
28
  await db`ALTER TABLE ai.inference_calls ADD COLUMN IF NOT EXISTS error TEXT`;
28
29
  await db`ALTER TABLE ai.inference_calls ADD COLUMN IF NOT EXISTS cancelled BOOLEAN NOT NULL DEFAULT false`;
package/src/ai/quotas.ts CHANGED
@@ -3,6 +3,7 @@ import { type AccessSubject, buildAccessPrincipalCondition } from "../server/ser
3
3
  import { hasBillableAiPricing } from "../shared/ai-costs";
4
4
  import { type AiQuotaConfig, AiQuotaConfigSchema, type AiQuotaIdentity, type AiQuotaSnapshot } from "../shared/ai-quotas";
5
5
  import { readAiSettingsState } from "./settings";
6
+ import type { AiSettingsState } from "./types";
6
7
 
7
8
  export class AiQuotaError extends Error {
8
9
  constructor(
@@ -43,10 +44,18 @@ export const aiQuotas = {
43
44
  return { ...config, revision };
44
45
  });
45
46
  },
46
- async snapshot(subject: AccessSubject, model?: string, now = new Date(), db: SQL = sql): Promise<AiQuotaSnapshot> {
47
- const config = await aiQuotas.config(db);
47
+ async snapshot(
48
+ subject: AccessSubject,
49
+ model?: string,
50
+ now = new Date(),
51
+ db: SQL = sql,
52
+ context?: { state: AiSettingsState; config: AiQuotaConfig },
53
+ ): Promise<AiQuotaSnapshot> {
54
+ const config = context?.config ?? (await aiQuotas.config(db));
48
55
  const identity = ids(subject);
49
- const pricedModels = new Set((await readAiSettingsState()).profiles.filter((p) => hasBillableAiPricing(p.pricing)).map((p) => p.id));
56
+ const pricedModels = new Set(
57
+ (context?.state ?? (await readAiSettingsState())).profiles.filter((p) => hasBillableAiPricing(p.pricing)).map((p) => p.modelId),
58
+ );
50
59
  const matches = buildAccessPrincipalCondition({
51
60
  subject,
52
61
  columns: {
@@ -76,7 +85,7 @@ export const aiQuotas = {
76
85
  (c.finished_at IS NOT NULL OR (c.lease_expires_at <= now() OR (c.turn_id IS NOT NULL AND NOT EXISTS(SELECT 1 FROM ai.turns t WHERE t.id=c.turn_id AND t.attempt=c.turn_attempt AND t.status='running' AND t.lease_expires_at>now())))))::int AS unknown
77
86
  FROM ai.inference_calls c
78
87
  WHERE c.kind='chat' AND c.user_id IS NOT DISTINCT FROM ${identity.user}::uuid AND c.service_account_id IS NOT DISTINCT FROM ${identity.service}::uuid
79
- AND (${rule.scope}='*' OR c.model_profile_id=${rule.scope})
88
+ AND (${rule.scope}='*' OR c.model_id=${rule.scope})
80
89
  AND c.started_at>=${window.from} AND c.started_at<${window.until}
81
90
  AND c.started_at>COALESCE((SELECT max(r.created_at) FROM ai.cost_resets r
82
91
  WHERE r.user_id IS NOT DISTINCT FROM ${identity.user}::uuid AND r.service_account_id IS NOT DISTINCT FROM ${identity.service}::uuid AND r.scope=${rule.scope}),'-infinity'::timestamptz)`;
@@ -110,13 +119,15 @@ export const aiQuotas = {
110
119
  GROUP BY model_profile_id ORDER BY model_profile_id`;
111
120
  return { enabled: config.enabled, unit: config.unit ?? "EUR", balances, usage };
112
121
  },
113
- async assertAllowed(subject: AccessSubject, model: string) {
122
+ async assertAllowed(subject: AccessSubject, variantId: string, providedState?: AiSettingsState) {
123
+ const state = providedState ?? (await readAiSettingsState());
124
+ const profile = state.profiles.find((profile) => profile.id === variantId);
125
+ if (!profile || !hasBillableAiPricing(profile.pricing)) return false;
126
+ const model = profile.modelId;
114
127
  // Fast default path: no additional identity/usage queries on unrestricted installations.
115
- const pricing = (await readAiSettingsState()).profiles.find((profile) => profile.id === model)?.pricing;
116
- if (!hasBillableAiPricing(pricing)) return false;
117
128
  const config = await aiQuotas.config();
118
129
  if (!config.enabled || !config.rules.some((r) => r.scope === "*" || r.scope === model)) return false;
119
- const snapshot = await aiQuotas.snapshot(subject, model);
130
+ const snapshot = await aiQuotas.snapshot(subject, model, undefined, undefined, { state, config });
120
131
  for (const b of snapshot.balances) {
121
132
  if (b.bypassed || b.limit === null) continue;
122
133
  if (b.unknown)
@@ -26,6 +26,8 @@ import type { AiConversationService, AiRecentResource } from "./types";
26
26
  export const AiRecentResourceSchema: z.ZodType<AiRecentResource> = z
27
27
  .object({
28
28
  ref: CloudResourceRefSchema,
29
+ context: LinkedCloudResourceReferenceSchema.shape.context,
30
+ status: LinkedCloudResourceReferenceSchema.shape.status,
29
31
  title: LinkedCloudResourceReferenceSchema.shape.title.unwrap(),
30
32
  icon: LinkedCloudResourceReferenceSchema.shape.icon,
31
33
  href: LinkedCloudResourceReferenceSchema.shape.href,
@@ -183,6 +185,8 @@ export const listAiRecentResources = async (input: {
183
185
  ref,
184
186
  count,
185
187
  title: data.title,
188
+ context: ownRef?.context,
189
+ status: ownRef?.status,
186
190
  icon: ownRef?.icon ?? data.icon,
187
191
  href,
188
192
  preview: recentResourcePreview(ownRef?.preview ?? data.description),
@@ -229,6 +233,8 @@ export const listAiRecentResources = async (input: {
229
233
  ref,
230
234
  count,
231
235
  title: ownRef.title,
236
+ context: ownRef.context,
237
+ status: ownRef.status,
232
238
  icon: (ownRef.icon ?? type.icon ?? app.appIcon)?.slice(0, 120),
233
239
  href: ownRef.href,
234
240
  kind: type.title.slice(0, 120),
@@ -1,117 +1,44 @@
1
1
  import { sql } from "bun";
2
- import { toPgTextArray } from "../services/postgres";
3
2
  import { decryptValue, encryptValue } from "../services/settings/crypto";
4
- import {
5
- AI_REQUEST_HEADERS_PROVIDER_ERROR,
6
- AiRequestHeadersSchema,
7
- patchAiRequestHeaders,
8
- providerSupportsRequestHeaders,
9
- } from "../shared/ai-request-options";
10
- import type { AiModelProfile } from "./types";
3
+ import { AiRequestHeadersSchema, patchAiRequestHeaders } from "../shared/ai-request-options";
11
4
 
12
5
  type SqlClient = typeof sql;
13
- export type AiRequestHeaderPatch = { profileId: string; patch: unknown };
14
6
 
15
- const decryptHeaders = async (profileId: string, secret: string): Promise<Record<string, string>> => {
7
+ const decryptHeaders = async (providerId: string, secret: string): Promise<Record<string, string>> => {
16
8
  try {
17
9
  const parsed = AiRequestHeadersSchema.safeParse(await decryptValue(secret));
18
10
  if (parsed.success && Object.values(parsed.data).every((value) => typeof value === "string"))
19
11
  return patchAiRequestHeaders({}, parsed.data);
20
12
  } catch {}
21
- console.warn(`[ai] ignoring unreadable request headers for profile ${JSON.stringify(profileId)}`);
13
+ console.warn(`[ai] ignoring unreadable request headers for provider ${JSON.stringify(providerId)}`);
22
14
  return {};
23
15
  };
24
16
 
25
17
  /** Secrets stay server-side, in a separate encrypted row like provider API keys. */
26
- export const getAiRequestHeaders = async (profileId: string, db: SqlClient = sql): Promise<Record<string, string>> => {
27
- const [row] = await db<{ secret: string }[]>`SELECT secret FROM ai.model_request_headers WHERE profile_id = ${profileId}`;
28
- return row ? decryptHeaders(profileId, row.secret) : {};
18
+ export const getAiRequestHeaders = async (providerId: string, db: SqlClient = sql): Promise<Record<string, string>> => {
19
+ const [row] = await db<{ secret: string }[]>`SELECT secret FROM ai.provider_request_headers WHERE provider_id = ${providerId}`;
20
+ return row ? decryptHeaders(providerId, row.secret) : {};
29
21
  };
30
22
 
31
- export const setAiRequestHeaders = async (profileId: string, headers: Record<string, string>, db: SqlClient = sql): Promise<void> => {
23
+ export const setAiRequestHeaders = async (providerId: string, headers: Record<string, string>, db: SqlClient = sql): Promise<void> => {
32
24
  const normalized = patchAiRequestHeaders({}, headers);
33
25
  if (!Object.keys(normalized).length) {
34
- await db`DELETE FROM ai.model_request_headers WHERE profile_id = ${profileId}`;
26
+ await db`DELETE FROM ai.provider_request_headers WHERE provider_id = ${providerId}`;
35
27
  return;
36
28
  }
37
29
  const encrypted = await encryptValue(normalized);
38
- await db`INSERT INTO ai.model_request_headers (profile_id, secret) VALUES (${profileId}, ${encrypted})
39
- ON CONFLICT (profile_id) DO UPDATE SET secret = EXCLUDED.secret, updated_at = now()`;
30
+ await db`INSERT INTO ai.provider_request_headers (provider_id, secret) VALUES (${providerId}, ${encrypted})
31
+ ON CONFLICT (provider_id) DO UPDATE SET secret = EXCLUDED.secret, updated_at = now()`;
40
32
  };
41
33
 
42
- /** Admin reads contain names only. Never serialize header values into profiles. */
34
+ /** Admin reads contain names only. Never serialize header values into providers. */
43
35
  export const listAiRequestHeaderNames = async (db: SqlClient = sql): Promise<Record<string, string[]>> => {
44
- const rows = await db<{ profile_id: string; secret: string }[]>`SELECT profile_id, secret FROM ai.model_request_headers`;
36
+ const rows = await db<{ provider_id: string; secret: string }[]>`SELECT provider_id, secret FROM ai.provider_request_headers`;
45
37
  const names: Record<string, string[]> = {};
46
38
  for (const row of rows)
47
- Object.defineProperty(names, row.profile_id, {
48
- value: Object.keys(await decryptHeaders(row.profile_id, row.secret)).sort(),
39
+ Object.defineProperty(names, row.provider_id, {
40
+ value: Object.keys(await decryptHeaders(row.provider_id, row.secret)).sort(),
49
41
  enumerable: true,
50
42
  });
51
43
  return names;
52
44
  };
53
-
54
- export const pruneAiRequestHeaders = async (keepProfileIds: readonly string[], db: SqlClient = sql): Promise<void> => {
55
- if (!keepProfileIds.length) await db`DELETE FROM ai.model_request_headers`;
56
- else await db`DELETE FROM ai.model_request_headers WHERE profile_id <> ALL(${toPgTextArray([...keepProfileIds])}::text[])`;
57
- };
58
-
59
- /** Follow credentials: changing provider discards stored secrets; omission preserves them on the same provider. */
60
- export const planAiProfileRequestHeaders = (input: {
61
- currentProfiles: readonly AiModelProfile[];
62
- nextProfiles: readonly AiModelProfile[];
63
- existingNames: Record<string, string[]>;
64
- submitted: readonly AiRequestHeaderPatch[];
65
- }): { keepHeaderProfileIds: string[]; patches: { profileId: string; patch: Record<string, string | null> }[]; error?: string } => {
66
- const current = new Map(input.currentProfiles.map((profile) => [profile.id, profile]));
67
- const keepHeaderProfileIds = input.nextProfiles
68
- .filter(
69
- (profile) =>
70
- providerSupportsRequestHeaders(profile.provider) &&
71
- current.get(profile.id)?.provider === profile.provider &&
72
- !profile.capabilities.includes("transcription"),
73
- )
74
- .map((profile) => profile.id);
75
- const patches: { profileId: string; patch: Record<string, string | null> }[] = [];
76
- for (const submitted of input.submitted) {
77
- const profile = input.nextProfiles.find((profile) => profile.id === submitted.profileId);
78
- const parsed = AiRequestHeadersSchema.safeParse(submitted.patch);
79
- if (!parsed.success)
80
- return { keepHeaderProfileIds: [], patches: [], error: `requestHeaders: ${zodHeaderMessage(parsed.error.issues)}` };
81
- if (!profile || !providerSupportsRequestHeaders(profile.provider))
82
- return { keepHeaderProfileIds: [], patches: [], error: AI_REQUEST_HEADERS_PROVIDER_ERROR };
83
- if (profile.capabilities.includes("transcription"))
84
- return {
85
- keepHeaderProfileIds: [],
86
- patches: [],
87
- error: "requestHeaders: Request settings are not supported on transcription profiles.",
88
- };
89
- const oldNames =
90
- keepHeaderProfileIds.includes(profile.id) && Object.hasOwn(input.existingNames, profile.id) ? input.existingNames[profile.id]! : [];
91
- try {
92
- patchAiRequestHeaders(Object.fromEntries(oldNames.map((name) => [name, ""])), parsed.data);
93
- } catch {
94
- return {
95
- keepHeaderProfileIds: [],
96
- patches: [],
97
- error: "requestHeaders: At most 32 extra headers are allowed after applying the patch.",
98
- };
99
- }
100
- patches.push({ profileId: profile.id, patch: parsed.data });
101
- }
102
- return { keepHeaderProfileIds, patches };
103
- };
104
-
105
- const zodHeaderMessage = (issues: readonly { path: readonly PropertyKey[]; message: string }[]) =>
106
- issues.map((issue) => `${issue.path.map(String).join(".") || "headers"}: ${issue.message}`).join("; ");
107
-
108
- /** Apply after pruning so a provider change cannot inherit old secrets. Call inside the settings transaction. */
109
- export const storeAiRequestHeaderPlan = async (
110
- plan: ReturnType<typeof planAiProfileRequestHeaders>,
111
- db: SqlClient = sql,
112
- ): Promise<void> => {
113
- if (plan.error) throw new Error(plan.error);
114
- await pruneAiRequestHeaders(plan.keepHeaderProfileIds, db);
115
- for (const { profileId, patch } of plan.patches)
116
- await setAiRequestHeaders(profileId, patchAiRequestHeaders(await getAiRequestHeaders(profileId, db), patch), db);
117
- };
@@ -13,7 +13,7 @@ import type { AiConversationResourceObservation } from "./types";
13
13
 
14
14
  const LinkedResourceReferenceSchema = CloudResourceReferenceSchema.extend({
15
15
  href: CloudResourceHrefSchema.optional(),
16
- });
16
+ }).strip();
17
17
 
18
18
  export const addCloudResourceHrefs = <T>(
19
19
  value: T,
@@ -67,12 +67,14 @@ export const collectConversationResourceObservations = (...values: unknown[]): A
67
67
  return;
68
68
  }
69
69
 
70
- const view = CloudResourceViewSchema.safeParse(value);
70
+ const view = CloudResourceViewSchema.strip().safeParse(value);
71
71
  if (view.success) {
72
72
  const resource: CloudResourceView = view.data;
73
73
  observations.set(observationKey(resource.ref), {
74
74
  ref: resource.ref,
75
75
  title: resource.title,
76
+ context: resource.context,
77
+ status: resource.status,
76
78
  preview: resource.preview,
77
79
  icon: resource.icon,
78
80
  href: resource.links.find((link) => link.rel === "open")?.href,
@@ -87,6 +89,8 @@ export const collectConversationResourceObservations = (...values: unknown[]): A
87
89
  ...previous,
88
90
  ref,
89
91
  ...(resource.title !== undefined ? { title: resource.title } : {}),
92
+ ...(resource.context !== undefined ? { context: resource.context } : {}),
93
+ ...(resource.status !== undefined ? { status: resource.status } : {}),
90
94
  ...(resource.preview !== undefined ? { preview: resource.preview } : {}),
91
95
  ...(resource.icon !== undefined ? { icon: resource.icon } : {}),
92
96
  ...(resource.href !== undefined ? { href: resource.href } : {}),
package/src/ai/routes.ts CHANGED
@@ -37,7 +37,7 @@ import { coreSettings } from "../services/settings/api";
37
37
  import { resolveAppIdentityPresentation } from "../shared/app-presentation";
38
38
  import { readThemeFromCookieHeader } from "../shared/theme";
39
39
  import { type AiStoredActionReview, isLegacyActionReview, isUnknownActionOutcome, storedActionReview } from "./action-review";
40
- import { assistantAiSettingsState, listAssistantAiModels, resolveAssistantAudioModel, selectAssistantAiModelId } from "./assistant-models";
40
+ import { assistantAiSettingsState, peopleAiModels, resolveAssistantAudioModel, selectAssistantAiModel } from "./assistant-models";
41
41
  import { AI_AUDIO_MAX_BYTES } from "./audio-format";
42
42
  import { buildAiCapabilityCatalog } from "./capabilities";
43
43
  import { checkAiCapability, resolveAiCapabilityActor } from "./capability-execution";
@@ -254,10 +254,10 @@ const publicMemory = async (userId: string, memory: Awaited<ReturnType<typeof ai
254
254
  (await publicMemories(userId, [memory]))[0]!;
255
255
 
256
256
  /** Fire-and-forget: remember the model this user actually ran a turn with (preselected for new chats). */
257
- const rememberLastUsedModel = (actor: RequestActor, modelProfileId: string | null | undefined): void => {
257
+ const rememberLastUsedModel = (actor: RequestActor, model: import("../shared/ai-models").AiModelRequest | undefined): void => {
258
258
  const userId = aiPrefsUserId(actor);
259
- if (!userId || !modelProfileId) return;
260
- void aiUserPrefs.update(userId, { lastModelId: modelProfileId }).catch(() => undefined);
259
+ if (!userId || !model) return;
260
+ void aiUserPrefs.update(userId, { selection: model }).catch(() => undefined);
261
261
  };
262
262
 
263
263
  const conversationDetail = async (conversation: AiConversation, subject: AccessSubject) => {
@@ -348,7 +348,7 @@ export const aiRoutes = (() => {
348
348
  return c.json(await getAiChatQuotas(c.get("accessSubject")));
349
349
  })
350
350
  .get("/models", async (c) => {
351
- return respond(c, ok(await listAssistantAiModels(c.get("accessSubject"))));
351
+ return respond(c, ok(await peopleAiModels(c.get("accessSubject"))));
352
352
  })
353
353
  .get("/prefs", async (c) => {
354
354
  const ctx = await resolveContext(c);
@@ -438,12 +438,11 @@ export const aiRoutes = (() => {
438
438
  const state = await readAiSettingsState();
439
439
  let previewProfile: ReturnType<typeof selectAiModelProfile> | null = null;
440
440
  if (state.ok && state.enabled) {
441
- const models = await listAssistantAiModels(c.get("accessSubject"), ctx.modelPolicy);
442
- const preferredId =
443
- models.find((model) => model.id === prefs?.lastModelId)?.id ??
444
- models.find((model) => model.id === state.defaultModelId)?.id ??
445
- models[0]?.id;
446
- if (preferredId) previewProfile = selectAiModelProfile(state, ctx.modelPolicy, preferredId);
441
+ try {
442
+ previewProfile = selectAiModelProfile(state, ctx.modelPolicy, prefs.selection ?? undefined);
443
+ } catch {
444
+ /* The prompt preview remains available without a usable model. */
445
+ }
447
446
  }
448
447
  const toolsSupported = Boolean(previewProfile?.capabilities.includes("tools"));
449
448
  const availableSkills = toolsSupported
@@ -1022,7 +1021,7 @@ export const aiRoutes = (() => {
1022
1021
  locale: getLocale(c),
1023
1022
  theme: readThemeFromCookieHeader(c.req.header("cookie")),
1024
1023
  timeZone: getTimeZone(c),
1025
- requestedModelId: body.modelProfileId ?? project?.defaultModelProfileId ?? undefined,
1024
+ model: body.model ?? project?.defaultModel ?? undefined,
1026
1025
  modelPolicy: ctx.modelPolicy,
1027
1026
  project: project ?? undefined,
1028
1027
  clientToolIds: body.clientToolIds,
@@ -1043,7 +1042,7 @@ export const aiRoutes = (() => {
1043
1042
  return respond(c, ok({ queued: true }));
1044
1043
  }
1045
1044
  const result = await submitAiChatTurn(submission);
1046
- rememberLastUsedModel(ctx.actor, result.turn.modelProfileId);
1045
+ rememberLastUsedModel(ctx.actor, body.model);
1047
1046
  return respond(
1048
1047
  c,
1049
1048
  ok({
@@ -1115,9 +1114,9 @@ export const aiRoutes = (() => {
1115
1114
  if (originalProject && !currentProject) return respond(c, fail(err.notFound("Project")));
1116
1115
 
1117
1116
  try {
1118
- const requestedModelId = await selectAssistantAiModelId(
1117
+ const model = await selectAssistantAiModel(
1119
1118
  c.get("accessSubject"),
1120
- body.modelProfileId ?? originalProject?.defaultModelProfileId ?? undefined,
1119
+ body.model ?? originalProject?.defaultModel ?? undefined,
1121
1120
  ctx.modelPolicy,
1122
1121
  );
1123
1122
  if ((await prepareConversationForMessageRetry(conversation.id)) === "busy") {
@@ -1134,7 +1133,7 @@ export const aiRoutes = (() => {
1134
1133
  locale: getLocale(c),
1135
1134
  theme: readThemeFromCookieHeader(c.req.header("cookie")),
1136
1135
  timeZone: getTimeZone(c),
1137
- requestedModelId,
1136
+ model,
1138
1137
  modelPolicy: ctx.modelPolicy,
1139
1138
  systemPrompt,
1140
1139
  project: originalProject,
@@ -1158,7 +1157,7 @@ export const aiRoutes = (() => {
1158
1157
  }
1159
1158
  : undefined,
1160
1159
  });
1161
- rememberLastUsedModel(ctx.actor, result.turn.modelProfileId);
1160
+ rememberLastUsedModel(ctx.actor, body.model);
1162
1161
  return respond(
1163
1162
  c,
1164
1163
  ok({
@@ -1230,7 +1229,7 @@ export const aiRoutes = (() => {
1230
1229
  const result = await submitAiCompaction({
1231
1230
  conversationId: conversation.id,
1232
1231
  actor: ctx.actor,
1233
- requestedModelId: body.modelProfileId,
1232
+ model: body.model,
1234
1233
  modelPolicy: ctx.modelPolicy,
1235
1234
  });
1236
1235
  return respond(c, ok({ turn: publicTurn(result.turn, conversation.shortId) }), 201);
package/src/ai/runtime.ts CHANGED
@@ -8,6 +8,7 @@ import { type ApprovalDecisionChannel, approvals } from "../services/approvals";
8
8
  import { logger } from "../services/logging";
9
9
  import { superviseRuntimeTask } from "../services/runtime-lifecycle";
10
10
  import { coreSettings } from "../services/settings/api";
11
+ import type { AiModelRequest } from "../shared/ai-models";
11
12
  import {
12
13
  AI_WEBSITE_APPROVAL_TOOL,
13
14
  type AiToolApprovalChangeContext,
@@ -15,16 +16,17 @@ import {
15
16
  aiTurnAllowsRememberedApprovals,
16
17
  rememberAiToolApproval,
17
18
  } from "./approvals";
18
- import { aiChatAccessSubject, selectAssistantAiModelId } from "./assistant-models";
19
+ import { aiChatAccessSubject, selectAssistantAiModel } from "./assistant-models";
19
20
  import { startAiDictationRuntime } from "./dictation-runtime";
20
21
  import { AiTurnExecutor, type ExecutorConfig } from "./executor";
21
22
  import { canonicalizeAiConversationAttachments, snapshotAiConversationFiles } from "./file-context";
22
23
  import type { AiLocalClient, AiLocalContext } from "./local-tool-contracts";
23
24
  import { drainQueuedMessages } from "./message-queue";
25
+ import { aiUserPrefs } from "./prefs";
24
26
  import { AI_TURN_LEASE_MS } from "./protocol";
25
27
  import { aiQuotas } from "./quotas";
26
28
  import { parseAiResourceMarker } from "./resource-markers";
27
- import { isAiVisionModelConfigured } from "./settings";
29
+ import { isAiVisionModelConfigured, readAiSettingsState } from "./settings";
28
30
  import { aiConversations } from "./store";
29
31
  import { publishAiWireEvent } from "./stream";
30
32
  import { aiToolAudit } from "./tool-audit";
@@ -110,7 +112,7 @@ export type SubmitAiChatTurnInput = {
110
112
  theme?: "light" | "dark";
111
113
  timeZone?: string;
112
114
  modelPolicy?: AiModelPolicy;
113
- requestedModelId?: string;
115
+ model?: AiModelRequest;
114
116
  /** Optional instructions that apply only to this turn. */
115
117
  systemPrompt?: string;
116
118
  project?: AiChatTurnRunConfig["project"];
@@ -143,21 +145,43 @@ export const prepareAiChatTurn = async (input: SubmitAiChatTurnInput) => {
143
145
  );
144
146
  const canonicalInput = files?.attached.length ? inputMessage.content : input.input;
145
147
  const hasImageAttachments = Boolean(files?.attached.some((file) => isAiImageMediaType(file.mediaType)));
148
+ const state = await readAiSettingsState();
146
149
  const canInspectAttachedImages =
147
150
  input.canInspectAttachedImages ??
148
- (input.toolSource?.kind === "default" && (await isAiVisionModelConfigured(input.modelPolicy?.allowedDataBoundaries)));
149
- const requestedModelId = input.assistantChat
150
- ? await selectAssistantAiModelId(aiChatAccessSubject(input.actor), input.requestedModelId, input.modelPolicy)
151
- : input.requestedModelId;
152
- const { resolved } = await validateAiTurnRequest({
153
- input: canonicalInput,
154
- hasImageAttachments,
155
- canInspectAttachedImages,
156
- modelPolicy: input.modelPolicy,
157
- requestedModelId,
158
- });
151
+ (input.toolSource?.kind === "default" && (await isAiVisionModelConfigured(input.modelPolicy?.allowedDataBoundaries, state)));
152
+ let requested = input.model;
153
+ let previousLabel: string | undefined;
154
+ if (input.assistantChat) {
155
+ const latest = await aiConversations.getLatestTurn({ conversationId: input.conversationId, kind: "chat" });
156
+ const previous = latest ? await aiConversations.getTurnRunConfig({ conversationId: input.conversationId, turnId: latest.id }) : null;
157
+ const inherited = previous?.kind === "chat" ? previous.model : undefined;
158
+ requested ??= inherited;
159
+ if (inherited && "id" in inherited && requested && "id" in requested && requested.id === inherited.id) {
160
+ if (!state.profiles.some((profile) => profile.id === inherited.id)) {
161
+ previousLabel = previous?.kind === "chat" ? (previous.modelLabel ?? inherited.id) : inherited.id;
162
+ const subject = aiChatAccessSubject(input.actor);
163
+ const prefs = subject?.type === "user" ? await aiUserPrefs.get(subject.userId) : null;
164
+ requested = prefs?.selection && "class" in prefs.selection ? prefs.selection : { class: "balanced" };
165
+ }
166
+ }
167
+ }
168
+ const model = input.assistantChat
169
+ ? await selectAssistantAiModel(aiChatAccessSubject(input.actor), requested, input.modelPolicy, state)
170
+ : requested;
171
+ const { resolved } = await validateAiTurnRequest(
172
+ {
173
+ input: canonicalInput,
174
+ hasImageAttachments,
175
+ canInspectAttachedImages,
176
+ modelPolicy: input.modelPolicy,
177
+ model,
178
+ },
179
+ state,
180
+ );
159
181
  const quotaSubject = aiChatAccessSubject(input.actor);
160
- if (input.assistantChat && quotaSubject) await aiQuotas.assertAllowed(quotaSubject, resolved.profile.id);
182
+ if (input.assistantChat && quotaSubject) await aiQuotas.assertAllowed(quotaSubject, resolved.profile.id, state);
183
+ const modelLabel =
184
+ resolved.profile.label + (resolved.profile.id !== resolved.profile.modelId ? ` · ${resolved.profile.variantLabel}` : "");
161
185
  const runConfig: AiChatTurnRunConfig = {
162
186
  kind: "chat",
163
187
  selectedSkillIds: [
@@ -177,7 +201,10 @@ export const prepareAiChatTurn = async (input: SubmitAiChatTurnInput) => {
177
201
  ...(input.theme ? { theme: input.theme } : {}),
178
202
  ...(input.timeZone ? { timeZone: input.timeZone } : {}),
179
203
  modelPolicy: input.modelPolicy,
180
- requestedModelId,
204
+ model,
205
+ modelResolved: resolved.model,
206
+ modelLabel,
207
+ ...(previousLabel ? { modelReplacement: { kind: "model_replaced", previousLabel, nextLabel: modelLabel } } : {}),
181
208
  systemPrompt: input.systemPrompt,
182
209
  project: input.project,
183
210
  files,
@@ -191,7 +218,7 @@ export const prepareAiChatTurn = async (input: SubmitAiChatTurnInput) => {
191
218
 
192
219
  return {
193
220
  conversationId: input.conversationId,
194
- modelProfileId: resolved.profile.id,
221
+ modelId: resolved.profile.id,
195
222
  runConfig,
196
223
  userMessage,
197
224
  truncateFromSeq: input.truncateFromSeq,
@@ -244,7 +271,7 @@ export const deliverAiInterChatMessage = async (input: {
244
271
  };
245
272
  const delivered = await aiConversations.deliverInterChatMessage({
246
273
  messageId: input.message.id,
247
- modelProfileId: resolved.profile.id,
274
+ modelId: resolved.profile.id,
248
275
  runConfig,
249
276
  userMessage: { role: "user", content: [{ type: "text", text }] },
250
277
  sourceHref: input.sourceHref,
@@ -257,20 +284,21 @@ export type SubmitAiCompactionInput = {
257
284
  conversationId: string;
258
285
  actor?: RequestActor;
259
286
  modelPolicy?: AiModelPolicy;
260
- requestedModelId?: string;
287
+ model?: AiModelRequest;
261
288
  };
262
289
 
263
290
  export const submitAiCompaction = async (input: SubmitAiCompactionInput): Promise<{ turn: AiTurn }> => {
264
- const { resolved } = await validateAiTurnRequest({ input: "", modelPolicy: input.modelPolicy, requestedModelId: input.requestedModelId });
291
+ const { resolved } = await validateAiTurnRequest({ input: "", modelPolicy: input.modelPolicy, model: input.model });
265
292
  const runConfig: AiCompactionTurnRunConfig = {
266
293
  kind: "compact",
267
294
  actor: input.actor,
268
295
  modelPolicy: input.modelPolicy,
269
- requestedModelId: input.requestedModelId,
296
+ model: { id: resolved.profile.id },
297
+ modelResolved: resolved.model,
270
298
  };
271
299
  const turn = await aiConversations.createCompactionTurn({
272
300
  conversationId: input.conversationId,
273
- modelProfileId: resolved.profile.id,
301
+ modelId: resolved.profile.id,
274
302
  runConfig,
275
303
  });
276
304
  await enqueueAiTurn({ conversationId: input.conversationId, turnId: turn.id });