@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
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.");
@@ -12,6 +12,7 @@ import {
12
12
  } from "../server/services/access";
13
13
  import { stripImageMetadata } from "../services/image-metadata";
14
14
  import { toPgUuidArray } from "../services/postgres";
15
+ import { type AiModelRequest, AiModelRequestSchema } from "../shared/ai-models";
15
16
  import { AiFileVersionConflict, AiFileWriteError, aiFileContentVersion } from "./file-content-version";
16
17
  import { isAiImage } from "./file-media-type";
17
18
  import { mountAiProjectFilePath } from "./file-mount";
@@ -42,7 +43,7 @@ export type AiProject = {
42
43
  description: string;
43
44
  icon: string;
44
45
  instructions: string;
45
- defaultModelProfileId: string | null;
46
+ defaultModel: AiModelRequest | null;
46
47
  permission: AiProjectPermission;
47
48
  revision: number;
48
49
  createdAt: string;
@@ -115,7 +116,7 @@ type ProjectRow = {
115
116
  description: string;
116
117
  icon: string;
117
118
  instructions: string;
118
- default_model_profile_id: string | null;
119
+ default_model: AiModelRequest | null;
119
120
  revision: number;
120
121
  created_at: Date | string;
121
122
  updated_at: Date | string;
@@ -213,7 +214,7 @@ const toProject = async (row: ProjectRow, subject: AccessSubject | null, db: SQL
213
214
  description: row.description,
214
215
  icon: row.icon,
215
216
  instructions: row.instructions,
216
- defaultModelProfileId: row.default_model_profile_id,
217
+ defaultModel: row.default_model ? AiModelRequestSchema.parse(row.default_model) : null,
217
218
  permission,
218
219
  revision: Number(row.revision),
219
220
  createdAt: iso(row.created_at),
@@ -417,17 +418,17 @@ export const aiProjects = {
417
418
  description?: string;
418
419
  icon?: string;
419
420
  instructions?: string;
420
- defaultModelProfileId?: string | null;
421
+ defaultModel?: AiModelRequest | null;
421
422
  }): Promise<AiProject> {
422
423
  return sql.begin(async (tx) => {
423
424
  const rows = await withAiShortIdForDb(
424
425
  tx,
425
426
  "idx_ai_projects_short_id",
426
427
  (attempt, shortId) => attempt<ProjectRow[]>`
427
- INSERT INTO ai.projects (short_id, name, description, icon, instructions, default_model_profile_id)
428
+ INSERT INTO ai.projects (short_id, name, description, icon, instructions, default_model)
428
429
  VALUES (
429
430
  ${shortId}, ${input.name.trim()}, ${input.description?.trim() ?? ""}, ${input.icon?.trim() || "ti ti-folders"},
430
- ${input.instructions?.trim() ?? ""}, ${input.defaultModelProfileId?.trim() || null}
431
+ ${input.instructions?.trim() ?? ""}, ${input.defaultModel ? JSON.stringify(AiModelRequestSchema.parse(input.defaultModel)) : null}::text::jsonb
431
432
  )
432
433
  RETURNING *
433
434
  `,
@@ -488,9 +489,9 @@ export const aiProjects = {
488
489
  async update(
489
490
  projectId: string,
490
491
  subject: AccessSubject | null,
491
- input: Partial<Pick<AiProject, "name" | "description" | "icon" | "instructions" | "defaultModelProfileId">>,
492
+ input: Partial<Pick<AiProject, "name" | "description" | "icon" | "instructions" | "defaultModel">>,
492
493
  ): Promise<AiProject | null> {
493
- const changesTrustedInstructions = Object.hasOwn(input, "instructions") || Object.hasOwn(input, "defaultModelProfileId");
494
+ const changesTrustedInstructions = Object.hasOwn(input, "instructions") || Object.hasOwn(input, "defaultModel");
494
495
  if (!(await requireProject(projectId, subject, changesTrustedInstructions ? "admin" : "write"))) return null;
495
496
  const rows = await sql<ProjectRow[]>`
496
497
  UPDATE ai.projects
@@ -498,9 +499,9 @@ export const aiProjects = {
498
499
  description = COALESCE(${input.description?.trim() ?? null}, description),
499
500
  icon = COALESCE(${input.icon?.trim() ?? null}, icon),
500
501
  instructions = COALESCE(${input.instructions?.trim() ?? null}, instructions),
501
- default_model_profile_id = CASE
502
- WHEN ${Object.hasOwn(input, "defaultModelProfileId")} THEN ${input.defaultModelProfileId?.trim() || null}
503
- ELSE default_model_profile_id
502
+ default_model = CASE
503
+ WHEN ${Object.hasOwn(input, "defaultModel")} THEN ${input.defaultModel ? JSON.stringify(AiModelRequestSchema.parse(input.defaultModel)) : null}::text::jsonb
504
+ ELSE default_model
504
505
  END,
505
506
  revision = revision + 1,
506
507
  updated_at = now()
@@ -563,7 +564,7 @@ export const aiProjects = {
563
564
  instructions: project.instructions,
564
565
  context: lines.join("\n").slice(0, AI_PROJECT_PROMPT_MANIFEST_MAX_CHARS),
565
566
  references: references.map((reference) => reference.ref),
566
- defaultModelProfileId: project.defaultModelProfileId,
567
+ defaultModel: project.defaultModel,
567
568
  };
568
569
  });
569
570
  },
@@ -90,7 +90,7 @@ export type AiTurnFinishedStatus = "completed" | "failed" | "aborted";
90
90
  export type AiWireEvent =
91
91
  | (AiWireEventBase & {
92
92
  type: "turn_started";
93
- modelProfileId: string;
93
+ modelId: string;
94
94
  providerModel: string;
95
95
  /** Authoritative ordered baseline for this attempt. Optional for older senders. */
96
96
  blocks?: AiTurnBlock[];
@@ -116,7 +116,7 @@ export type AiTurnSnapshot = {
116
116
  /** Last wire seq reflected in `blocks` (attempt-scoped). */
117
117
  seq: number;
118
118
  blocks: AiTurnBlock[];
119
- modelProfileId: string | null;
119
+ modelId: string | null;
120
120
  createdAt: string;
121
121
  /** Time the turn already waited for answered user actions such as approvals. Absent from older servers. */
122
122
  actionWaitMs?: number;
@@ -0,0 +1,161 @@
1
+ import { sql } from "bun";
2
+ import { z } from "zod";
3
+ import type { AiProvider } from "../shared/ai-models";
4
+ import { getAiCredential } from "./credentials";
5
+ import { getAiRequestHeaders } from "./request-headers";
6
+
7
+ export type AiProviderTestResult = {
8
+ status: "ok" | "unreachable" | "key_rejected" | "error";
9
+ httpStatus?: number;
10
+ message: string;
11
+ latencyMs?: number;
12
+ };
13
+ export type AiAvailableProviderModel = { providerModel: string; label?: string; kind?: "chat" | "transcription"; contextWindow?: number };
14
+ type Connection = Pick<AiProvider, "type" | "baseUrl"> & { apiKey?: string; headers?: Record<string, string> };
15
+ const defaults = {
16
+ openai: "https://api.openai.com/v1",
17
+ openrouter: "https://openrouter.ai/api/v1",
18
+ anthropic: "https://api.anthropic.com/v1",
19
+ mistral: "https://api.mistral.ai/v1",
20
+ gemini: "https://generativelanguage.googleapis.com/v1beta/openai",
21
+ ollama: "http://localhost:11434",
22
+ vllm: "http://localhost:8000/v1",
23
+ "openai-compatible": "",
24
+ };
25
+ export class AiProviderListError extends Error {
26
+ constructor(
27
+ readonly code: "unreachable" | "key_rejected" | "list_failed",
28
+ message: string,
29
+ readonly httpStatus?: number,
30
+ ) {
31
+ super(message);
32
+ }
33
+ }
34
+ export const classifyProviderFailure = (error: unknown): AiProviderTestResult => {
35
+ const details = z
36
+ .object({ status: z.number().optional(), statusCode: z.number().optional(), code: z.string().optional(), name: z.string().optional() })
37
+ .passthrough()
38
+ .safeParse(error);
39
+ const httpStatus =
40
+ error instanceof AiProviderListError
41
+ ? error.httpStatus
42
+ : details.success
43
+ ? (details.data.status ?? details.data.statusCode)
44
+ : undefined;
45
+ const status =
46
+ httpStatus === 401 || httpStatus === 403
47
+ ? "key_rejected"
48
+ : (error instanceof AiProviderListError && error.code === "unreachable") ||
49
+ error instanceof TypeError ||
50
+ (details.success && ["TimeoutError", "AbortError"].includes(details.data.name ?? "")) ||
51
+ (details.success && ["ECONNREFUSED", "ETIMEDOUT", "ENOTFOUND", "ECONNRESET"].includes(details.data.code ?? ""))
52
+ ? "unreachable"
53
+ : "error";
54
+ return {
55
+ status,
56
+ ...(httpStatus ? { httpStatus } : {}),
57
+ message:
58
+ status === "key_rejected"
59
+ ? "The provider rejected the credential."
60
+ : status === "unreachable"
61
+ ? "The provider did not respond within 10 seconds or could not be reached."
62
+ : "The provider request failed.",
63
+ };
64
+ };
65
+ /** Best-effort persistence never rejects: callers await it inside their provider error boundary. */
66
+ export const recordAiProviderStatus = async (id: string, result: AiProviderTestResult, force = false) => {
67
+ try {
68
+ await sql`UPDATE ai.providers SET status=${result.status},status_code=${result.httpStatus?.toString() ?? null},status_message=${result.message},status_checked_at=clock_timestamp() WHERE id=${id} AND (${force} OR status IS DISTINCT FROM ${result.status} OR status_code IS DISTINCT FROM ${result.httpStatus?.toString() ?? null})`;
69
+ } catch {
70
+ // A failed database write must not start another write through the persistent logger.
71
+ console.warn("[ai:providers] Could not persist provider connection status", { providerId: id });
72
+ }
73
+ };
74
+ const requestModels = async (provider: Connection, signal?: AbortSignal, after?: string) => {
75
+ const base = (provider.baseUrl ?? defaults[provider.type]).replace(/\/$/, "");
76
+ if (!base) throw new AiProviderListError("list_failed", "The provider has no base URL.");
77
+ const headers = new Headers(provider.headers);
78
+ if (provider.type === "anthropic") {
79
+ headers.set("anthropic-version", "2023-06-01");
80
+ if (provider.apiKey) headers.set("x-api-key", provider.apiKey);
81
+ } else if (provider.apiKey) headers.set("authorization", `Bearer ${provider.apiKey}`);
82
+ let response: Response;
83
+ try {
84
+ response = await fetch(
85
+ `${base}${provider.type === "ollama" ? "/api/tags" : `/models${after ? `?after_id=${encodeURIComponent(after)}` : ""}`}`,
86
+ { headers, signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(10_000)]) : AbortSignal.timeout(10_000), redirect: "error" },
87
+ );
88
+ } catch {
89
+ throw new AiProviderListError("unreachable", "The provider could not be reached.");
90
+ }
91
+ if (!response.ok) {
92
+ await response.body?.cancel();
93
+ throw new AiProviderListError(
94
+ response.status === 401 || response.status === 403 ? "key_rejected" : "list_failed",
95
+ "The provider could not list models.",
96
+ response.status,
97
+ );
98
+ }
99
+ return response;
100
+ };
101
+ /** Cloud's one listing seam; move it to nessi listModels() when that API ships. */
102
+ export const listProviderModels = async (provider: Connection, signal?: AbortSignal): Promise<AiAvailableProviderModel[]> => {
103
+ const deadline = signal ? AbortSignal.any([signal, AbortSignal.timeout(10_000)]) : AbortSignal.timeout(10_000);
104
+ const models: AiAvailableProviderModel[] = [];
105
+ const seen = new Set<string>();
106
+ let after: string | undefined;
107
+ do {
108
+ const response = await requestModels(provider, deadline, after);
109
+ try {
110
+ const body: unknown = await response.json();
111
+ if (provider.type === "ollama")
112
+ return z
113
+ .object({ models: z.array(z.object({ name: z.string() })) })
114
+ .parse(body)
115
+ .models.map((model) => ({ providerModel: model.name }));
116
+ const page = z
117
+ .object({
118
+ data: z.array(
119
+ z.object({
120
+ id: z.string(),
121
+ name: z.string().optional(),
122
+ display_name: z.string().optional(),
123
+ context_length: z.number().int().positive().optional(),
124
+ }),
125
+ ),
126
+ has_more: z.boolean().optional(),
127
+ last_id: z.string().optional(),
128
+ })
129
+ .parse(body);
130
+ models.push(
131
+ ...page.data.map((model) => ({
132
+ providerModel: model.id,
133
+ ...((model.display_name ?? model.name) ? { label: model.display_name ?? model.name } : {}),
134
+ ...(model.context_length ? { contextWindow: model.context_length } : {}),
135
+ })),
136
+ );
137
+ if (provider.type !== "anthropic" || !page.has_more) return models;
138
+ after = page.last_id ?? page.data.at(-1)?.id;
139
+ if (!after || seen.has(after)) throw new Error("Non-advancing model cursor");
140
+ seen.add(after);
141
+ } catch {
142
+ throw new AiProviderListError("list_failed", "The provider returned an invalid model list.");
143
+ }
144
+ } while (!deadline.aborted);
145
+ throw new AiProviderListError("unreachable", "The provider model list timed out.");
146
+ };
147
+ export const storedProviderConnection = async (provider: AiProvider): Promise<Connection> => ({
148
+ ...provider,
149
+ apiKey: (await getAiCredential(provider.id)) ?? undefined,
150
+ headers: await getAiRequestHeaders(provider.id),
151
+ });
152
+ export const testAiProvider = async (provider: Connection, signal?: AbortSignal): Promise<AiProviderTestResult> => {
153
+ const start = Date.now();
154
+ try {
155
+ const response = await requestModels(provider, signal);
156
+ await response.body?.cancel();
157
+ return { status: "ok", httpStatus: response.status, message: "The provider connection works.", latencyMs: Date.now() - start };
158
+ } catch (error) {
159
+ return { ...classifyProviderFailure(error), latencyMs: Date.now() - start };
160
+ }
161
+ };
@@ -0,0 +1,138 @@
1
+ import type { NessiIssue, Provider, StreamEvent } from "@k2b/nessi/ai";
2
+ import { classifyProviderFailure, recordAiProviderStatus } from "./provider-connections";
3
+ import { type ProviderFetchMarks, runWithProviderFetchMarks } from "./provider-fetch";
4
+
5
+ /** No provider-owned message, name, stack, cause, or extension fields cross this boundary. */
6
+ export class AiProviderError extends Error {
7
+ readonly code: "provider_error" | "provider_aborted";
8
+
9
+ constructor(aborted = false) {
10
+ super(aborted ? "AI provider request was canceled." : "AI provider request failed.");
11
+ this.name = aborted ? "AbortError" : "AiProviderError";
12
+ this.code = aborted ? "provider_aborted" : "provider_error";
13
+ }
14
+ }
15
+
16
+ /** Keep only the adapter's typed control signals. Text and diagnostic payloads are untrusted. */
17
+ const providerIssue = (issue: NessiIssue, tools: Parameters<Provider["stream"]>[0]["tools"]): NessiIssue => {
18
+ switch (issue.kind) {
19
+ case "provider_error":
20
+ return {
21
+ kind: "provider_error",
22
+ message: "AI provider request failed.",
23
+ retryable: issue.retryable === true,
24
+ contextOverflow: issue.contextOverflow === true,
25
+ overflowRatio:
26
+ typeof issue.overflowRatio === "number" && Number.isFinite(issue.overflowRatio) && issue.overflowRatio > 0
27
+ ? issue.overflowRatio
28
+ : undefined,
29
+ };
30
+ case "timeout":
31
+ return {
32
+ kind: "timeout",
33
+ scope: issue.scope === "provider_first_byte" ? "provider_first_byte" : "provider_idle",
34
+ message: "AI provider request timed out.",
35
+ retryable: issue.retryable === true,
36
+ };
37
+ case "malformed_tool_call":
38
+ case "cancelled_tool_call":
39
+ return {
40
+ kind: issue.kind,
41
+ reason: toolStreamReason(issue.reason),
42
+ // Use the application-declared name, never an arbitrary provider diagnostic.
43
+ name: tools?.find((tool) => tool.name === issue.name)?.name,
44
+ message: "AI provider returned an incomplete or invalid tool call.",
45
+ };
46
+ default:
47
+ // Tool execution and runtime issues belong to nessi, not a model adapter.
48
+ return { kind: "provider_error", message: "AI provider request failed.", retryable: false };
49
+ }
50
+ };
51
+
52
+ const toolStreamReason = (reason: string) => {
53
+ switch (reason) {
54
+ case "text_during_tool_call":
55
+ case "thinking_during_tool_call":
56
+ case "tool_delta_without_start":
57
+ case "missing_tool_name":
58
+ case "invalid_tool_arguments":
59
+ case "stream_ended_before_tool_call":
60
+ case "provider_error_before_tool_call":
61
+ return reason;
62
+ default:
63
+ return "invalid_tool_arguments";
64
+ }
65
+ };
66
+
67
+ /** Apply before accounting, retry, nessi loops, traces, capabilities, and loggers observe an adapter. */
68
+ export const withProviderErrors = (provider: Provider, providerId?: string): Provider => ({
69
+ name: provider.name,
70
+ family: provider.family,
71
+ model: provider.model,
72
+ contextWindow: provider.contextWindow,
73
+ capabilities: provider.capabilities,
74
+ complete: async (request) => {
75
+ const marks: ProviderFetchMarks = {};
76
+ try {
77
+ const result = await runWithProviderFetchMarks(marks, () => provider.complete(request));
78
+ if (providerId && !request.signal?.aborted)
79
+ await recordAiProviderStatus(
80
+ providerId,
81
+ ["error", "aborted", "interrupted"].includes(result.finishReason ?? "")
82
+ ? classifyProviderFailure(marks.httpStatus ? { status: marks.httpStatus } : {})
83
+ : { status: "ok", message: "The provider connection works." },
84
+ );
85
+ return result;
86
+ } catch (error) {
87
+ if (providerId && !request.signal?.aborted)
88
+ await recordAiProviderStatus(
89
+ providerId,
90
+ classifyProviderFailure(marks.httpStatus ? { status: marks.httpStatus } : marks.networkFailure ? new TypeError() : error),
91
+ );
92
+ throw new AiProviderError(request.signal?.aborted);
93
+ }
94
+ },
95
+ stream: async function* (request) {
96
+ const marks: ProviderFetchMarks = {};
97
+ let iterator: AsyncIterator<StreamEvent> | undefined;
98
+ try {
99
+ const activeIterator = provider.stream(request)[Symbol.asyncIterator]();
100
+ iterator = activeIterator;
101
+ let failed = false;
102
+ for (;;) {
103
+ const next = await runWithProviderFetchMarks(marks, () => activeIterator.next());
104
+ if (next.done) break;
105
+ const event = next.value;
106
+ if (event.type === "issue" && (event.issue.kind === "provider_error" || event.issue.kind === "timeout")) {
107
+ failed = true;
108
+ if (providerId)
109
+ await recordAiProviderStatus(
110
+ providerId,
111
+ event.issue.kind === "timeout"
112
+ ? { status: "unreachable", message: "The provider timed out." }
113
+ : classifyProviderFailure(
114
+ marks.httpStatus ? { status: marks.httpStatus } : marks.networkFailure ? new TypeError() : event.issue,
115
+ ),
116
+ );
117
+ }
118
+ yield event.type === "issue" ? { type: "issue", issue: providerIssue(event.issue, request.tools) } : event;
119
+ }
120
+ if (providerId && !failed && !request.signal?.aborted)
121
+ await recordAiProviderStatus(providerId, { status: "ok", message: "The provider connection works." });
122
+ } catch (error) {
123
+ if (providerId && !request.signal?.aborted)
124
+ await recordAiProviderStatus(
125
+ providerId,
126
+ classifyProviderFailure(marks.httpStatus ? { status: marks.httpStatus } : marks.networkFailure ? new TypeError() : error),
127
+ );
128
+ // Includes synchronous creation, iteration, and iterator cleanup failures.
129
+ throw new AiProviderError(request.signal?.aborted);
130
+ } finally {
131
+ try {
132
+ await iterator?.return?.();
133
+ } catch {
134
+ throw new AiProviderError(request.signal?.aborted);
135
+ }
136
+ }
137
+ },
138
+ });
@@ -13,6 +13,8 @@ import { AsyncLocalStorage } from "node:async_hooks";
13
13
  */
14
14
  export type ProviderFetchMarks = {
15
15
  headersAt?: number;
16
+ httpStatus?: number;
17
+ networkFailure?: boolean;
16
18
  firstByteAt?: number;
17
19
  /** The request never reached the provider, or the provider answered with a status that says it did not process it. */
18
20
  refused?: boolean;
@@ -74,6 +76,7 @@ const install = (): void => {
74
76
  try {
75
77
  response = await realFetch(input, init);
76
78
  } catch (error) {
79
+ for (const store of stores) store.networkFailure = true;
77
80
  // A reset, an abort, or a timeout can follow delivery, so only an unsent request counts as refused.
78
81
  if (unsent(error))
79
82
  for (const store of stores) {
@@ -85,6 +88,7 @@ const install = (): void => {
85
88
  const wait = response.ok ? undefined : retryAfterMs(response.headers, headersAt);
86
89
  for (const store of stores) {
87
90
  store.headersAt = headersAt;
91
+ store.httpStatus = response.status;
88
92
  store.refused = refusedStatus(response.status);
89
93
  if (wait !== undefined) store.retryAfterMs = wait;
90
94
  }
@@ -8,7 +8,7 @@ export const AI_PROVIDER_RETRY_DELAYS_MS: readonly number[] = [1_000, 4_000];
8
8
  /**
9
9
  * The longest Retry-After that is honored, the bound the OpenAI and Anthropic
10
10
  * SDKs apply. A provider that asks for a longer wait is unavailable for this
11
- * turn, so the call fails now with the provider's message.
11
+ * turn, so the call fails now with Cloud's fixed provider error.
12
12
  */
13
13
  const AI_PROVIDER_MAX_RETRY_AFTER_MS = 60_000;
14
14
 
@@ -1,5 +1,6 @@
1
1
  import type { Provider, ReasoningEffort } from "@k2b/nessi/ai";
2
2
  import { anthropic, completeFromStream, gemini, mistral, ollama, openAICompatible, openai, openrouter } from "@k2b/nessi/ai";
3
+ import { withProviderErrors } from "./provider-errors";
3
4
  import type { AiModelProfile } from "./types";
4
5
 
5
6
  /** Bound the first response chunk and every gap between chunks to 60s, for chat and complete() calls. */
@@ -39,7 +40,7 @@ const commonOptions = (profile: AiModelProfile, apiKey?: string) => ({
39
40
 
40
41
  export const createAiProvider = (profile: AiModelProfile, apiKey?: string, headers?: Record<string, string>): Provider => {
41
42
  if (profile.capabilities.includes("transcription")) throw new Error("Transcription profiles cannot be used for chat generation.");
42
- return completeThroughStream(createAdapter(profile, apiKey, headers));
43
+ return withProviderErrors(completeThroughStream(createAdapter(profile, apiKey, headers)), profile.providerId);
43
44
  };
44
45
 
45
46
  const createAdapter = (profile: AiModelProfile, apiKey?: string, headers?: Record<string, string>): Provider => {
@@ -0,0 +1,36 @@
1
+ import { sql } from "bun";
2
+ import { recursiveGroupIdsSubquery } from "../services/accounts/group-sql";
3
+ import { toPgTextArray } from "../services/postgres";
4
+ import { hasBillableAiPricing } from "../shared/ai-costs";
5
+ import { readAiSettingsState } from "./settings";
6
+
7
+ /** One row per rule; identities are deduplicated when several model rules apply. */
8
+ export const aiPeopleAtLimit = async (db = sql): Promise<{ scope: string; people: string[] }[]> => {
9
+ const priced = [
10
+ ...new Set(
11
+ (await readAiSettingsState()).profiles.filter((profile) => hasBillableAiPricing(profile.pricing)).map((profile) => profile.modelId),
12
+ ),
13
+ ];
14
+ return db<{ scope: string; people: string[] }[]>`WITH rules AS MATERIALIZED (
15
+ SELECT r->>'scope' AS scope,r->'grants' AS grants,
16
+ (r->>'anchor')::timestamptz + floor(extract(epoch FROM (now()-(r->>'anchor')::timestamptz)) / ((r->>'hours')::int*3600)) * ((r->>'hours')::int*interval '1 hour') AS starts
17
+ FROM ai.cost_config,jsonb_array_elements(rules) r WHERE singleton AND enabled
18
+ AND (r->>'scope'='*' OR r->>'scope'=ANY(${toPgTextArray(priced)}::text[]))
19
+ ), people AS MATERIALIZED (
20
+ SELECT u.id FROM auth.users u WHERE EXISTS(SELECT 1 FROM ai.inference_calls WHERE kind='chat' AND user_id=u.id)
21
+ OR EXISTS(SELECT 1 FROM ai.conversations c JOIN ai.turns t ON t.conversation_id=c.id
22
+ WHERE c.created_by_user_id=u.id AND t.run_config->>'assistantChat'='true' AND t.run_config->'mandate' IS NULL)
23
+ ), allowances AS MATERIALIZED (
24
+ SELECT u.id,r.scope,r.starts,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
25
+ FROM people u CROSS JOIN rules r LEFT JOIN LATERAL (
26
+ SELECT g FROM jsonb_array_elements(r.grants) g WHERE g->'principal'->>'type'='authenticated'
27
+ OR g->'principal'->>'userId'=u.id::text OR (g->'principal'->>'groupId')::uuid IN (${recursiveGroupIdsSubquery(sql`u.id`)})
28
+ ) grants ON true GROUP BY u.id,r.scope,r.starts
29
+ ), exhausted AS (
30
+ SELECT a.scope,a.id FROM allowances a LEFT JOIN ai.inference_calls c ON c.user_id=a.id AND c.kind='chat'
31
+ AND (a.scope='*' OR c.model_id=a.scope) AND c.started_at>=a.starts
32
+ AND c.started_at>COALESCE((SELECT max(created_at) FROM ai.cost_resets z WHERE z.user_id=a.id AND z.scope=a.scope),'-infinity'::timestamptz)
33
+ WHERE a.amount IS NOT NULL AND NOT EXISTS(SELECT 1 FROM allowances w WHERE w.id=a.id AND w.scope='*' AND w.amount IS NULL)
34
+ GROUP BY a.scope,a.id,a.amount HAVING COALESCE(sum(c.cost),0)>=a.amount
35
+ ) SELECT scope,array_agg(id::text ORDER BY id) AS people FROM exhausted GROUP BY scope`;
36
+ };
@@ -3,11 +3,15 @@ import { estimateTokens, type Provider } from "@k2b/nessi";
3
3
  import { sql } from "bun";
4
4
  import type { AccessSubject } from "../server/services/access";
5
5
  import { logger } from "../services/logging";
6
+ import type { AiResolvedModelRef } from "../shared/ai-models";
6
7
  import { isAssistantChatTurn } from "./assistant-models";
7
8
  import { AiBackgroundAdmissionError, type AiCallContext, type AiCallDetails, beginAiCall, finishAiCall } from "./inference-calls";
8
9
  import { type ProviderFetchMarks, runWithProviderFetchMarks } from "./provider-fetch";
9
10
  import type { AiModelProfile } from "./types";
10
11
 
12
+ /** A dispatch guard refused the call before any provider request could leave. */
13
+ export class AiProviderAdmissionError extends Error {}
14
+
11
15
  const log = logger("ai:quotas");
12
16
 
13
17
  /** One wrapper owns call accounting. complete() can have a separate compaction purpose. */
@@ -25,6 +29,8 @@ export function inferenceProvider(
25
29
  completeContext = context,
26
30
  lifecycle = callLifecycle,
27
31
  ): Provider {
32
+ // createAiProvider normalizes adapter diagnostics before trusted dispatch guards wrap it.
33
+ // Accounting must preserve those guards' error identity and zero-usage semantics.
28
34
  const begin = async (request: Parameters<Provider["complete"]>[0], ctx: AiCallContext) => {
29
35
  const messages = request.messages.map((message) =>
30
36
  message.role === "user"
@@ -108,6 +114,7 @@ export function inferenceProvider(
108
114
  if (status === "failed") error = `The provider finished with ${result.finishReason}.`;
109
115
  return result;
110
116
  } catch (thrown) {
117
+ if (thrown instanceof AiProviderAdmissionError) usage = { input: 0, output: 0 };
111
118
  error = thrown instanceof Error ? thrown.message : String(thrown);
112
119
  throw thrown;
113
120
  } finally {
@@ -183,6 +190,7 @@ export function inferenceProvider(
183
190
  }
184
191
  completed = true;
185
192
  } catch (thrown) {
193
+ if (thrown instanceof AiProviderAdmissionError) usage = { input: 0, output: 0 };
186
194
  error ??= thrown instanceof Error ? thrown.message : String(thrown);
187
195
  throw thrown;
188
196
  } finally {
@@ -223,7 +231,7 @@ export function inferenceProvider(
223
231
  firstByteMs: marks.firstByteAt === undefined ? undefined : marks.firstByteAt - requestStartedAt,
224
232
  firstBlockMs: marks.firstBlockAt === undefined ? undefined : marks.firstBlockAt - requestStartedAt,
225
233
  });
226
- } catch (error) {
234
+ } catch {
227
235
  log.warn("Chat usage booking failed", { code: "quota_booking_failed", turnId: context.turnId, callId: id });
228
236
  }
229
237
  }
@@ -239,8 +247,9 @@ export function assistantQuotaProvider(
239
247
  profile: AiModelProfile,
240
248
  turnId: string,
241
249
  conversationId: string,
250
+ modelRef: AiResolvedModelRef,
242
251
  ) {
243
- const common = { subject, turnId, conversationId };
252
+ const common = { subject, turnId, conversationId, modelRef };
244
253
  return inferenceProvider(
245
254
  provider,
246
255
  profile,