@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
@@ -0,0 +1,234 @@
1
+ import { sql } from "bun";
2
+ import { z } from "zod";
3
+ import { recursiveGroupIdsSubquery } from "../services/accounts/group-sql";
4
+ import { toPgTextArray } from "../services/postgres";
5
+ import { type AiDeleteModelInput, type AiModel, type AiModelSlot, type AiProvider, aiReplacementProblem } from "../shared/ai-models";
6
+ import type { WorkflowBoundPlan, WorkflowIrStep, WorkflowJsonValue } from "../workflows/contracts";
7
+ import { hashWorkflowSource } from "../workflows/language/canonical";
8
+ import { publishWorkflowVersion, type WorkflowActivationInput } from "../workflows/store/definitions";
9
+ import { aiModelAccess } from "./model-access";
10
+ import { AiConfigError, aiModelCatalog, assertAiRevision, lockAiModelCatalog, validateAiSlot } from "./model-catalog";
11
+
12
+ export const validateAiReplacement = (source: AiProvider, target: AiProvider, original: AiModel, replacement: AiModel): void => {
13
+ const problem = aiReplacementProblem(source, target, original, replacement);
14
+ if (problem === "private_boundary")
15
+ throw new AiConfigError("invalid_configuration", "A private model cannot be replaced with a hosted model.");
16
+ if (problem === "incompatible_model")
17
+ throw new AiConfigError(
18
+ "invalid_configuration",
19
+ "The replacement must be enabled and support the original model's kind and capabilities.",
20
+ );
21
+ };
22
+ const referencedAiModels = (value: unknown): string[] => {
23
+ if (Array.isArray(value)) return value.flatMap(referencedAiModels);
24
+ if (value !== null && typeof value === "object") {
25
+ const parsed = z.record(z.string(), z.unknown()).parse(value);
26
+ return Object.entries(parsed).flatMap(([key, entry]) =>
27
+ key === "model"
28
+ ? (() => {
29
+ const model = z.object({ id: z.string() }).safeParse(entry);
30
+ return model.success ? [model.data.id] : referencedAiModels(entry);
31
+ })()
32
+ : referencedAiModels(entry),
33
+ );
34
+ }
35
+ return [];
36
+ };
37
+ const retargetJson = (value: WorkflowJsonValue, ids: string[], replacement: string): WorkflowJsonValue => {
38
+ if (Array.isArray(value)) return value.map((entry) => retargetJson(entry, ids, replacement));
39
+ if (value !== null && typeof value === "object")
40
+ return Object.fromEntries(
41
+ Object.entries(value).map(([key, entry]) => [
42
+ key,
43
+ key === "model" &&
44
+ entry !== null &&
45
+ !Array.isArray(entry) &&
46
+ typeof entry === "object" &&
47
+ typeof entry.id === "string" &&
48
+ ids.includes(entry.id)
49
+ ? { id: replacement }
50
+ : retargetJson(entry, ids, replacement),
51
+ ]),
52
+ );
53
+ return value;
54
+ };
55
+ const retargetSteps = (steps: WorkflowIrStep[], ids: string[], replacement: string): WorkflowIrStep[] =>
56
+ steps.map((step) => {
57
+ if (step.kind === "action")
58
+ return {
59
+ ...step,
60
+ config: Object.fromEntries(
61
+ Object.entries(step.config).map(([key, value]) => [
62
+ key,
63
+ key === "model" &&
64
+ value !== null &&
65
+ !Array.isArray(value) &&
66
+ typeof value === "object" &&
67
+ typeof value.id === "string" &&
68
+ ids.includes(value.id)
69
+ ? { id: replacement }
70
+ : value,
71
+ ]),
72
+ ),
73
+ };
74
+ if (step.kind === "if")
75
+ return { ...step, then: retargetSteps(step.then, ids, replacement), else: retargetSteps(step.else, ids, replacement) };
76
+ if (step.kind === "switch")
77
+ return {
78
+ ...step,
79
+ cases: step.cases.map((entry) => ({ ...entry, steps: retargetSteps(entry.steps, ids, replacement) })),
80
+ default: retargetSteps(step.default, ids, replacement),
81
+ };
82
+ return { ...step, steps: retargetSteps(step.steps, ids, replacement) };
83
+ });
84
+ export const deleteAiCatalogEntry = async (
85
+ kind: "provider" | "model" | "variant",
86
+ id: string,
87
+ input: AiDeleteModelInput,
88
+ actorId: string,
89
+ ) =>
90
+ sql.begin(async (db) => {
91
+ // One catalog lock orders deletion with assignment and catalog writes.
92
+ await lockAiModelCatalog(db);
93
+ const current =
94
+ kind === "provider"
95
+ ? await aiModelCatalog.providerDetail(id, db)
96
+ : kind === "model"
97
+ ? await aiModelCatalog.modelDetail(id, db)
98
+ : await aiModelCatalog.variant(id, db, true);
99
+ await assertAiRevision(current, input.revision, db);
100
+ const allModels = await aiModelCatalog.models(db),
101
+ allVariants = await aiModelCatalog.variants(db),
102
+ providers = await aiModelCatalog.providers(db);
103
+ const affectedModels =
104
+ kind === "provider"
105
+ ? allModels.filter((model) => model.providerId === id)
106
+ : allModels.filter((model) => model.id === (kind === "model" ? id : allVariants.find((v) => v.id === id)?.modelId));
107
+ if (kind === "variant" && allVariants.find((v) => v.id === id)?.slug === "standard")
108
+ throw new AiConfigError("invalid_configuration", "The standard variant can only be deleted with its model.");
109
+ const affected = allVariants.filter((v) => (kind === "variant" ? v.id === id : affectedModels.some((m) => m.id === v.modelId)));
110
+ const ids = affected.map((v) => v.id),
111
+ pgIds = toPgTextArray(ids);
112
+ const target = (variantId: string, sourceModel: AiModel, slot?: AiModelSlot) => {
113
+ const variant = allVariants.find((v) => v.id === variantId && !ids.includes(v.id));
114
+ const model = allModels.find((m) => m.id === variant?.modelId),
115
+ provider = providers.find((p) => p.id === model?.providerId),
116
+ source = providers.find((p) => p.id === sourceModel.providerId);
117
+ if (!model || !provider || !source)
118
+ throw new AiConfigError("invalid_configuration", "Choose a replacement outside the deleted selection.");
119
+ validateAiReplacement(source, provider, sourceModel, model);
120
+ if (slot) validateAiSlot(slot, model);
121
+ };
122
+ const assignments = (await aiModelCatalog.assignments(db)).filter((a) => a.variantId && ids.includes(a.variantId));
123
+ for (const assignment of assignments) {
124
+ if (!Object.hasOwn(input.replacements, assignment.slot))
125
+ throw new AiConfigError("invalid_configuration", "Every affected assignment needs a replacement or an explicit empty choice.");
126
+ const replacement = input.replacements[assignment.slot];
127
+ const sourceModel = affectedModels.find((m) => m.id === affected.find((v) => v.id === assignment.variantId)?.modelId);
128
+ if (replacement && sourceModel) target(replacement, sourceModel, assignment.slot);
129
+ }
130
+ if (input.retarget && !input.retargetTo) throw new AiConfigError("invalid_configuration", "Retargeting requires a replacement.");
131
+ for (const assignment of assignments)
132
+ await db`UPDATE ai.model_assignments SET variant_id=${input.replacements[assignment.slot] ?? null},revision=revision+1,updated_by=${actorId}::uuid,updated_at=clock_timestamp() WHERE slot=${assignment.slot}`;
133
+ const projects = await db<
134
+ { id: string; short_id: string; name: string; default_model: { id: string } }[]
135
+ >`SELECT id,short_id,name,default_model FROM ai.projects WHERE default_model->>'id'=ANY(${pgIds}::text[]) FOR UPDATE`;
136
+ const workflows = await db<
137
+ {
138
+ id: string;
139
+ name: string;
140
+ created_by_kind: string;
141
+ created_by_id: string | null;
142
+ source: string;
143
+ plan: WorkflowBoundPlan;
144
+ effect_budget: Record<string, number>;
145
+ }[]
146
+ >`SELECT w.id,w.name,w.created_by_kind,w.created_by_id,v.source,v.plan,v.effect_budget FROM workflows.workflow w JOIN workflows.version v ON v.id=w.active_version_id WHERE EXISTS(SELECT 1 FROM jsonb_path_query(v.plan,'$.**.model.id') ref WHERE ref #>> '{}' = ANY(${pgIds}::text[])) FOR UPDATE OF w`;
147
+ if (input.retarget && input.retargetTo) {
148
+ const references = [
149
+ ...projects.map((project) => project.default_model.id),
150
+ ...workflows.flatMap((workflow) => referencedAiModels(workflow.plan)),
151
+ ];
152
+ for (const model of affectedModels.filter((model) =>
153
+ affected.some((variant) => variant.modelId === model.id && references.includes(variant.id)),
154
+ ))
155
+ target(input.retargetTo, model);
156
+ }
157
+ const scheduled = await db<
158
+ { sponsor_user_id: string; name: string }[]
159
+ >`SELECT task.sponsor_user_id,left(task.prompt,120) AS name FROM ai.chat_tasks task JOIN ai.conversations c ON c.id=task.conversation_id JOIN ai.projects p ON p.id=c.project_id WHERE p.default_model->>'id'=ANY(${pgIds}::text[])`;
160
+ for (const task of scheduled)
161
+ await db`INSERT INTO ai.model_change_notices(user_id,name,retargeted) VALUES(${task.sponsor_user_id}::uuid,${task.name},${input.retarget})`;
162
+ for (const project of projects) {
163
+ const owners = await db<
164
+ { id: string }[]
165
+ >`SELECT DISTINCT u.id FROM ai.project_access p JOIN auth.access a ON a.id=p.access_id JOIN auth.users u ON a.user_id=u.id OR a.authenticated_only OR a.group_id IN (${recursiveGroupIdsSubquery(db`u.id`)}) WHERE p.project_id=${project.id}::uuid AND a.permission='admin'`;
166
+ for (const owner of owners)
167
+ await db`INSERT INTO ai.model_change_notices(user_id,name,retargeted) VALUES(${owner.id}::uuid,${project.name},${input.retarget})`;
168
+ if (input.retarget)
169
+ await db`UPDATE ai.projects SET default_model=${JSON.stringify({ id: input.retargetTo })}::text::jsonb,revision=revision+1,updated_at=clock_timestamp() WHERE id=${project.id}::uuid`;
170
+ }
171
+ for (const workflow of workflows) {
172
+ if (workflow.created_by_kind === "user" && workflow.created_by_id)
173
+ await db`INSERT INTO ai.model_change_notices(user_id,name,retargeted) VALUES(${workflow.created_by_id}::uuid,${workflow.name},${input.retarget})`;
174
+ if (!input.retarget || !input.retargetTo) continue;
175
+ const activations = await db<
176
+ (WorkflowActivationInput & { event_type: string; authorization_snapshot: WorkflowJsonValue })[]
177
+ >`SELECT key,event_type,config,enabled,authorization_snapshot FROM workflows.activation WHERE workflow_id=${workflow.id}::uuid`;
178
+ const parsed: WorkflowJsonValue = z.json().parse(Bun.YAML.parse(workflow.source));
179
+ const source = Bun.YAML.stringify(retargetJson(parsed, ids, input.retargetTo));
180
+ const plan: WorkflowBoundPlan = {
181
+ ...workflow.plan,
182
+ sourceHash: await hashWorkflowSource(source),
183
+ steps: retargetSteps(workflow.plan.steps, ids, input.retargetTo),
184
+ bindings: Object.fromEntries(
185
+ Object.entries(workflow.plan.bindings).map(([key, value]) => [
186
+ key,
187
+ key === "model" &&
188
+ value !== null &&
189
+ !Array.isArray(value) &&
190
+ typeof value === "object" &&
191
+ typeof value.id === "string" &&
192
+ ids.includes(value.id)
193
+ ? { id: input.retargetTo! }
194
+ : retargetJson(value, ids, input.retargetTo!),
195
+ ]),
196
+ ),
197
+ };
198
+ await publishWorkflowVersion(
199
+ {
200
+ workflowId: workflow.id,
201
+ source,
202
+ plan,
203
+ effectBudget: workflow.effect_budget,
204
+ author: { kind: "user", id: actorId },
205
+ activations: activations.map((a) => ({ ...a, eventType: a.event_type })),
206
+ },
207
+ { db },
208
+ );
209
+ for (const activation of activations)
210
+ await db`UPDATE workflows.activation SET authorization_snapshot=${JSON.stringify(activation.authorization_snapshot)}::text::jsonb WHERE workflow_id=${workflow.id}::uuid AND key=${activation.key}`;
211
+ }
212
+ if (kind === "variant") await db`DELETE FROM ai.model_variants WHERE id=${id}`;
213
+ else if (kind === "model") await db`DELETE FROM ai.models WHERE id=${id}`;
214
+ else await db`DELETE FROM ai.providers WHERE id=${id}`;
215
+ if (kind !== "variant") {
216
+ const modelIds = affectedModels.map((m) => m.id);
217
+ const [config] = await db<{ rules: WorkflowJsonValue[] }[]>`SELECT rules FROM ai.cost_config WHERE singleton FOR UPDATE`;
218
+ const rules = (config?.rules ?? []).filter(
219
+ (rule) =>
220
+ rule === null ||
221
+ Array.isArray(rule) ||
222
+ typeof rule !== "object" ||
223
+ typeof rule.scope !== "string" ||
224
+ !modelIds.includes(rule.scope),
225
+ );
226
+ await db`UPDATE ai.cost_config SET rules=${JSON.stringify(rules)}::text::jsonb,revision=revision+1 WHERE singleton`;
227
+ await aiModelAccess.syncModels(
228
+ (await aiModelCatalog.models(db)).map((model) => model.id),
229
+ [],
230
+ db,
231
+ );
232
+ }
233
+ return { deleted: true };
234
+ });
@@ -0,0 +1,39 @@
1
+ import type { AiModelAssignment, AiModelSlot } from "../shared/ai-models";
2
+ import type { AiModelProfile, AiSettingsState } from "./types";
3
+
4
+ /** Fixed snapshots for tests; production reads the catalog. */
5
+ export const aiModelFixture = (overrides: Partial<AiModelProfile> = {}): AiModelProfile => ({
6
+ id: "chat",
7
+ modelId: "chat",
8
+ providerId: "provider",
9
+ label: "Chat",
10
+ variantLabel: "Standard",
11
+ description: "For everyday work",
12
+ icon: { kind: "provider" },
13
+ showInPicker: true,
14
+ provider: "ollama",
15
+ model: "chat",
16
+ enabled: true,
17
+ capabilities: ["tools"],
18
+ dataBoundary: "private",
19
+ ...overrides,
20
+ });
21
+ export const aiAssignmentFixture = (slot: AiModelSlot, variantId: string | null): AiModelAssignment => ({
22
+ slot,
23
+ variantId,
24
+ revision: 1,
25
+ updatedAt: "2026-10-16T00:00:00.000Z",
26
+ updatedBy: null,
27
+ });
28
+ export const aiSettingsFixture = (overrides: Partial<AiSettingsState> = {}): AiSettingsState => ({
29
+ ok: true,
30
+ enabled: true,
31
+ profiles: [aiModelFixture()],
32
+ assignments: [aiAssignmentFixture("balanced", "chat")],
33
+ globalInstructions: "",
34
+ localClientInstructions: "",
35
+ compactionInstructions: "",
36
+ maxToolResultChars: 2_000_000,
37
+ firecrawlConfigured: false,
38
+ ...overrides,
39
+ });
@@ -0,0 +1,36 @@
1
+ import type { JSX } from "solid-js";
2
+ import { type AiModelIcon as AiModelIconSetting, type AiPeopleModelIcon, aiProviderMark } from "../shared/ai-models";
3
+
4
+ /** The icon a person sees for a model: its emoji or image, else its provider's neutral mark. */
5
+ export const aiModelDisplayIcon = (icon: AiModelIconSetting, provider: { id: string; label: string }): AiPeopleModelIcon =>
6
+ icon.kind === "image"
7
+ ? { kind: "image", src: icon.dataUrl }
8
+ : icon.kind === "emoji"
9
+ ? icon
10
+ : { kind: "mark", ...aiProviderMark(provider) };
11
+
12
+ const sizes = { xs: "size-5 rounded-[5px] text-[9px]", sm: "size-7 rounded-md text-[10px]", md: "size-10 rounded-lg text-xs" } as const;
13
+
14
+ /**
15
+ * A model's icon, the one mark that tells models apart in a chooser. It is decoration: the model's name always sits
16
+ * next to it, so it stays hidden from assistive technology.
17
+ */
18
+ export function AiModelIcon(props: { icon: AiPeopleModelIcon; size?: keyof typeof sizes; class?: string }): JSX.Element {
19
+ const box = () =>
20
+ `inline-grid shrink-0 place-items-center overflow-hidden leading-none ${sizes[props.size ?? "xs"]} ${props.class ?? ""}`;
21
+ const icon = () => props.icon;
22
+ return (
23
+ <span class={box()} aria-hidden="true" data-ai-model-icon={icon().kind}>
24
+ {(() => {
25
+ const current = icon();
26
+ if (current.kind === "image") return <img src={current.src} alt="" class="size-full object-cover" />;
27
+ if (current.kind === "emoji") return <span class="text-[1.15em]">{current.emoji}</span>;
28
+ return (
29
+ <span class="grid size-full place-items-center font-semibold tracking-wide text-white" style={{ background: current.color }}>
30
+ {current.text}
31
+ </span>
32
+ );
33
+ })()}
34
+ </span>
35
+ );
36
+ }
@@ -0,0 +1,53 @@
1
+ import { coreSettings } from "../services";
2
+ import { AI_MODEL_CLASSES, type AiModelClass, type AiModelSlot, type AiProvider } from "../shared/ai-models";
3
+ import { aiModelCatalog } from "./model-catalog";
4
+ import { resolveAiModelRef } from "./settings";
5
+
6
+ export type AiAdminOverview = {
7
+ enabled: boolean;
8
+ providers: (Pick<
9
+ AiProvider,
10
+ "id" | "type" | "label" | "enabled" | "status" | "statusCode" | "statusMessage" | "statusCheckedAt" | "logo" | "credentialSet"
11
+ > & {
12
+ modelCount: number;
13
+ })[];
14
+ assignments: Record<AiModelSlot, { variantId: string | null; revision: number; updatedAt: string; updatedBy: string | null }>;
15
+ effective: Record<AiModelClass, { class: AiModelClass | null; variantId: string | null }>;
16
+ };
17
+
18
+ /** What the AI area's first column and the class rows need, read once for the page and the API. */
19
+ export const readAiAdminOverview = async (): Promise<AiAdminOverview> => {
20
+ const [enabled, providers, models, assignments] = await Promise.all([
21
+ coreSettings.get<boolean>("ai.enabled"),
22
+ aiModelCatalog.providers(),
23
+ aiModelCatalog.models(),
24
+ aiModelCatalog.assignments(),
25
+ ]);
26
+ return {
27
+ enabled,
28
+ providers: providers.map((p) => ({
29
+ id: p.id,
30
+ type: p.type,
31
+ label: p.label,
32
+ enabled: p.enabled,
33
+ status: p.status,
34
+ statusCode: p.statusCode,
35
+ statusMessage: p.statusMessage,
36
+ statusCheckedAt: p.statusCheckedAt,
37
+ credentialSet: p.credentialSet,
38
+ modelCount: models.filter((m) => m.providerId === p.id).length,
39
+ logo: p.logo,
40
+ })),
41
+ assignments: Object.fromEntries(assignments.map(({ slot, ...assignment }) => [slot, assignment])) as AiAdminOverview["assignments"],
42
+ effective: Object.fromEntries(
43
+ AI_MODEL_CLASSES.map((modelClass) => {
44
+ try {
45
+ const ref = resolveAiModelRef({ enabled: true, assignments }, { class: modelClass });
46
+ return [modelClass, { class: ref.class ?? null, variantId: ref.id }];
47
+ } catch {
48
+ return [modelClass, { class: null, variantId: null }];
49
+ }
50
+ }),
51
+ ) as AiAdminOverview["effective"],
52
+ };
53
+ };
@@ -0,0 +1,45 @@
1
+ import { sql } from "bun";
2
+ import { toPgTextArray } from "../services/postgres";
3
+ import type { AiModelUsage } from "../shared/ai-models";
4
+ import { aiModelCatalog } from "./model-catalog";
5
+ import { aiPeopleAtLimit } from "./quota-limits";
6
+ import { aiQuotas } from "./quotas";
7
+
8
+ export const aiModelUsage = async (variantIds: string[], modelIds: string[], db = sql): Promise<AiModelUsage> => {
9
+ const ids = toPgTextArray(variantIds);
10
+ const [assignments, projects, workflows, scheduledTasks, counts, stats, config, people] = await Promise.all([
11
+ aiModelCatalog.assignments(db),
12
+ db<
13
+ { id: string; name: string }[]
14
+ >`SELECT short_id AS id,name FROM ai.projects WHERE default_model->>'id'=ANY(${ids}::text[]) ORDER BY name,id`,
15
+ db<
16
+ { id: string; name: string }[]
17
+ >`SELECT w.id,w.name FROM workflows.workflow w JOIN workflows.version v ON v.id=w.active_version_id WHERE EXISTS (SELECT 1 FROM jsonb_path_query(v.plan,'$.**.model.id') ref WHERE ref #>> '{}' = ANY(${ids}::text[])) ORDER BY w.name,w.id`,
18
+ db<
19
+ { id: string; name: string }[]
20
+ >`SELECT task.short_id AS id,left(task.prompt,120) AS name FROM ai.chat_tasks task JOIN ai.conversations c ON c.id=task.conversation_id LEFT JOIN ai.projects p ON p.id=c.project_id WHERE p.default_model->>'id'=ANY(${ids}::text[]) ORDER BY task.id`,
21
+ db<{ chats: number }[]>`SELECT count(DISTINCT conversation_id)::int AS chats FROM ai.turns WHERE model_profile_id=ANY(${ids}::text[])`,
22
+ db<AiModelUsage["stats"][]>`WITH answers AS (
23
+ SELECT COALESCE(turn_id,id) AS answer, CASE WHEN bool_and(cost IS NOT NULL) THEN sum(cost) END AS cost,
24
+ extract(epoch FROM max(finished_at)-min(started_at)) AS seconds
25
+ FROM ai.inference_calls WHERE model_profile_id=ANY(${ids}::text[]) AND started_at>=now()-interval '14 days'
26
+ AND kind='chat' AND status='ok' GROUP BY COALESCE(turn_id,id)
27
+ ) SELECT count(*)::int AS "answers14d", CASE WHEN count(*)>=20 THEN avg(cost)::float8 END AS "avgCostPerAnswer", CASE WHEN count(*)>=20 THEN avg(seconds)::float8 END AS "avgSecondsPerAnswer" FROM answers`,
28
+ aiQuotas.config(db),
29
+ // Count exhausted people per rule, then deduplicate global and model scopes.
30
+ aiPeopleAtLimit(db),
31
+ ]);
32
+ return {
33
+ assignments: assignments
34
+ .filter((assignment) => assignment.variantId && variantIds.includes(assignment.variantId))
35
+ .map((assignment) => assignment.slot),
36
+ projects,
37
+ workflows,
38
+ scheduledTasks,
39
+ chats: counts[0]?.chats ?? 0,
40
+ budgetRules: config.rules.filter((rule) => modelIds.includes(rule.scope)).map((rule) => ({ id: rule.scope, label: rule.scope })),
41
+ peopleAtLimit: new Set(people.filter((rule) => rule.scope === "*" || modelIds.includes(rule.scope)).flatMap((rule) => rule.people))
42
+ .size,
43
+ stats: stats[0] ?? { answers14d: 0, avgCostPerAnswer: null, avgSecondsPerAnswer: null },
44
+ };
45
+ };
@@ -14,8 +14,6 @@ const catalog = i18n.define({
14
14
  context_length_exceeded: "Your messages exceed this model's context window. Shorten them and try again.",
15
15
  request_too_large: "The request body exceeds the limit derived from your available models' context windows.",
16
16
  ai_disabled: "AI is turned off. Contact an administrator.",
17
- model_access_denied: "You do not have access to this model. Contact an administrator.",
18
- missing_provider_credential: "This model's provider is not configured. Contact an administrator.",
19
17
  insufficient_quota:
20
18
  "Your Assistant usage limit is reached, or your usage could not be measured. Wait for the reset or contact an administrator.",
21
19
  too_many_concurrent_requests: "You have too many simultaneous requests. Wait for a request to finish and try again.",
@@ -35,8 +33,6 @@ const catalog = i18n.define({
35
33
  context_length_exceeded: "Deine Nachrichten überschreiten das Kontextfenster dieses Modells. Kürze sie und versuche es erneut.",
36
34
  request_too_large: "Deine Anfrage überschreitet die aus den Kontextfenstern deiner verfügbaren Modelle abgeleitete Größenbegrenzung.",
37
35
  ai_disabled: "KI ist ausgeschaltet. Wende dich an einen Administrator.",
38
- model_access_denied: "Dieses Modell ist nicht für dich freigegeben. Wende dich an einen Administrator.",
39
- missing_provider_credential: "Der Anbieter dieses Modells ist nicht eingerichtet. Wende dich an einen Administrator.",
40
36
  insufficient_quota:
41
37
  "Dein Nutzungslimit im Assistenten ist erreicht, oder deine Nutzung konnte nicht gemessen werden. Warte auf das Zurücksetzen oder wende dich an einen Administrator.",
42
38
  too_many_concurrent_requests: "Du hast zu viele gleichzeitige Anfragen. Warte, bis eine Anfrage beendet ist, und versuche es erneut.",
@@ -1,4 +1,5 @@
1
1
  import { redis } from "bun";
2
+ import type { AccessSubject } from "../server/services/access";
2
3
  import { logger } from "../services/logging";
3
4
 
4
5
  const LEASE_MS = 120_000;
@@ -20,8 +21,15 @@ const SLOT_SCRIPT = `
20
21
  return 1
21
22
  `;
22
23
 
23
- export const acquire = async ({ userId, limit }: { userId: string; limit: number }): Promise<{ release(): Promise<void> } | null> => {
24
- const key = `cloud:ai:openai-api:slots:${userId}`;
24
+ export const acquire = async ({
25
+ subject,
26
+ limit,
27
+ }: {
28
+ subject: AccessSubject;
29
+ limit: number;
30
+ }): Promise<{ release(): Promise<void> } | null> => {
31
+ const principal = subject.type === "user" ? `user:${subject.userId}` : `service_account:${subject.serviceAccountId}`;
32
+ const key = `cloud:ai:openai-api:slots:${principal}`;
25
33
  const slot = crypto.randomUUID();
26
34
  const granted = await redis.send("EVAL", [SLOT_SCRIPT, "1", key, slot, String(LEASE_MS), String(limit)]);
27
35
  if (granted !== 1) return null;
@@ -9,12 +9,15 @@ import { type AuthContext, getLocale, rateLimit } from "../server";
9
9
  import { REQUEST_IDLE_TIMEOUT_SECONDS, setRequestTimeout } from "../server/request-timeout";
10
10
  import { authenticateModelsOnlyCredential } from "../services/identity/models-only-auth";
11
11
  import { coreSettings } from "../services/settings/api";
12
+ import { AI_MODEL_CLASSES, AiModelRequestSchema } from "../shared/ai-models";
12
13
  import { listAssistantAiModels } from "./assistant-models";
14
+ import { aiModelAccess } from "./model-access";
15
+ import { aiModelCatalog } from "./model-catalog";
13
16
  import { type OpenAiErrorCode, openAiApiMessages } from "./openai-api-messages";
14
17
  import { acquire } from "./openai-api-slots";
15
18
  import { personalAiModelPolicy } from "./personal-agent";
16
19
  import { PROVIDER_TIMEOUTS } from "./provider";
17
- import { inferenceProvider } from "./quota-provider";
20
+ import { AiProviderAdmissionError, inferenceProvider } from "./quota-provider";
18
21
  import { AiQuotaError } from "./quotas";
19
22
  import { resolveAiModel } from "./settings";
20
23
 
@@ -70,12 +73,16 @@ const usageBody = (usage: Usage) => ({
70
73
  total_tokens: usage.input + usage.output,
71
74
  });
72
75
 
76
+ class ModelsCredentialRevoked extends AiProviderAdmissionError {}
77
+
73
78
  type Dependencies = {
74
79
  limit?: MiddlewareHandler<AuthContext>;
75
80
  settings?: Pick<typeof coreSettings, "get">;
76
81
  authenticate?: typeof authenticateModelsOnlyCredential;
77
82
  listModels?: typeof listAssistantAiModels;
83
+ assignments?: typeof aiModelCatalog.assignments;
78
84
  resolveModel?: typeof resolveAiModel;
85
+ assertModelAccess?: typeof aiModelAccess.assertAllowed;
79
86
  acquire?: typeof acquire;
80
87
  inferenceProvider?: typeof inferenceProvider;
81
88
  keepaliveMs?: number;
@@ -85,21 +92,23 @@ export const createAiOpenAiRoutes = (dependencies: Dependencies = {}) => {
85
92
  const settings = dependencies.settings ?? coreSettings;
86
93
  const authenticate = dependencies.authenticate ?? authenticateModelsOnlyCredential;
87
94
  const listModels = dependencies.listModels ?? listAssistantAiModels;
95
+ const assignments = dependencies.assignments ?? (() => aiModelCatalog.assignments());
88
96
  const resolveModel = dependencies.resolveModel ?? resolveAiModel;
97
+ const assertModelAccess = dependencies.assertModelAccess ?? aiModelAccess.assertAllowed;
89
98
  const acquireSlot = dependencies.acquire ?? acquire;
90
99
  const wrapProvider = dependencies.inferenceProvider ?? inferenceProvider;
91
100
  const limit = dependencies.limit ?? rateLimit();
92
101
  const app = new Hono<AuthContext>();
93
102
  app.onError((error, c) => {
103
+ if (error instanceof ModelsCredentialRevoked) return errorResponse(c, 401, "invalid_api_key", "authentication_error");
94
104
  if (error instanceof AiQuotaError && error.code !== "quota_conflict")
95
105
  return errorResponse(c, 429, "insufficient_quota", "insufficient_quota");
96
106
  if (error instanceof Error && "aiError" in error) {
97
107
  const parsed = z.object({ code: z.string() }).safeParse(error.aiError);
98
108
  if (parsed.success) {
99
109
  if (parsed.data.code === "ai_disabled") return errorResponse(c, 403, "ai_disabled", "permission_error");
100
- if (parsed.data.code === "model_access_denied") return errorResponse(c, 403, "model_access_denied", "permission_error", "model");
101
- if (parsed.data.code === "model_policy_mismatch") return errorResponse(c, 404, "model_not_found", "invalid_request_error", "model");
102
- if (parsed.data.code === "missing_provider_credential") return errorResponse(c, 502, "missing_provider_credential", "api_error");
110
+ if (parsed.data.code === "model_policy_mismatch" || parsed.data.code === "ai_no_model")
111
+ return errorResponse(c, 404, "model_not_found", "invalid_request_error", "model");
103
112
  }
104
113
  }
105
114
  return errorResponse(c, 500, "internal_error", "api_error");
@@ -115,8 +124,7 @@ export const createAiOpenAiRoutes = (dependencies: Dependencies = {}) => {
115
124
  const token = c.req.header("Authorization")?.match(/^Bearer\s+(\S+)$/i)?.[1];
116
125
  const credential = await authenticate(token);
117
126
  if (!credential) return errorResponse(c, 401, "invalid_api_key", "authentication_error");
118
- c.set("user", credential.user);
119
- c.set("accessSubject", { type: "user", userId: credential.user.id });
127
+ c.set("accessSubject", credential.subject);
120
128
  c.header("Cache-Control", "no-store");
121
129
  await next();
122
130
  })
@@ -125,15 +133,22 @@ export const createAiOpenAiRoutes = (dependencies: Dependencies = {}) => {
125
133
  describeRoute({
126
134
  tags: ["AI"],
127
135
  summary: "List OpenAI-compatible models",
128
- description: "Models-only OpenAI-compatible surface: lists the Assistant chat models this person may use.",
136
+ description: "Models-only OpenAI-compatible surface: lists the Assistant chat models this account may use.",
129
137
  }),
130
138
  async (c) => {
131
139
  const models = await listModels(c.get("accessSubject"));
132
140
  return c.json({
133
141
  object: "list",
134
- data: models
135
- .filter((model) => !model.capabilities.includes("transcription"))
136
- .map((model) => ({ id: model.id, object: "model", created: 0, owned_by: "cloud" })),
142
+ data: [
143
+ ...models.filter((model) => !model.capabilities.includes("transcription")).map((model) => model.id),
144
+ ...(await assignments()).flatMap((assignment) =>
145
+ AI_MODEL_CLASSES.some((cls) => cls === assignment.slot) &&
146
+ assignment.variantId &&
147
+ models.some((model) => model.id === assignment.variantId)
148
+ ? [`cloud/${assignment.slot}`]
149
+ : [],
150
+ ),
151
+ ].map((id) => ({ id, object: "model", created: 0, owned_by: "cloud" })),
137
152
  });
138
153
  },
139
154
  )
@@ -150,7 +165,7 @@ export const createAiOpenAiRoutes = (dependencies: Dependencies = {}) => {
150
165
  const models = (await listModels(subject)).filter((model) => !model.capabilities.includes("transcription"));
151
166
  if (models.length === 0) return errorResponse(c, 404, "model_not_found", "invalid_request_error", "model");
152
167
  const slot = await acquireSlot({
153
- userId: c.get("user").id,
168
+ subject,
154
169
  limit: await settings.get<number>("ai.openai_api_max_concurrent_requests"),
155
170
  });
156
171
  if (!slot) {
@@ -199,9 +214,15 @@ export const createAiOpenAiRoutes = (dependencies: Dependencies = {}) => {
199
214
  else if (message.role === "user") messages.push({ role: "user", content: [text] });
200
215
  else messages.push({ role: "assistant", content: [{ type: "text", text }] });
201
216
  }
202
- if (!models.some((model) => model.id === data.model))
217
+ if (
218
+ !models.some((model) => model.id === data.model) &&
219
+ !(data.model.startsWith("cloud/") && AI_MODEL_CLASSES.some((cls) => `cloud/${cls}` === data.model))
220
+ )
203
221
  return errorResponse(c, 404, "model_not_found", "invalid_request_error", "model");
204
- const resolved = await resolveModel(personalAiModelPolicy, data.model);
222
+ const resolved = await resolveModel(
223
+ personalAiModelPolicy,
224
+ AiModelRequestSchema.parse(data.model.startsWith("cloud/") ? { class: data.model.slice(6) } : { id: data.model }),
225
+ );
205
226
  const systemPrompt = system.join("\n\n");
206
227
  const inputTokens = estimateTokens(messages) + Math.ceil(systemPrompt.length / 4);
207
228
  if (resolved.provider.contextWindow !== undefined && inputTokens > resolved.provider.contextWindow)
@@ -230,7 +251,47 @@ export const createAiOpenAiRoutes = (dependencies: Dependencies = {}) => {
230
251
  ? { generationConfig: { topP: data.top_p } }
231
252
  : { top_p: data.top_p },
232
253
  };
233
- const provider: Provider = wrapProvider(resolved.provider, resolved.profile, { kind: "chat", task: "openai-api", subject });
254
+ const token = c.req.header("Authorization")?.match(/^Bearer\s+(\S+)$/i)?.[1];
255
+ const credential = await authenticate(token);
256
+ if (!credential) throw new ModelsCredentialRevoked();
257
+ // Runs inside quota accounting, after admission and immediately before provider dispatch.
258
+ const admit = async () => {
259
+ try {
260
+ const current = await authenticate(token);
261
+ if (
262
+ !current ||
263
+ current.credentialId !== credential.credentialId ||
264
+ JSON.stringify(current.subject) !== JSON.stringify(subject)
265
+ )
266
+ throw new ModelsCredentialRevoked();
267
+ await assertModelAccess(resolved.profile.modelId, subject);
268
+ } catch (error) {
269
+ if (error instanceof ModelsCredentialRevoked) throw error;
270
+ if (error instanceof Error && "aiError" in error)
271
+ throw Object.assign(new AiProviderAdmissionError(error.message), { aiError: error.aiError });
272
+ throw new AiProviderAdmissionError("Model admission could not be checked");
273
+ }
274
+ };
275
+ const guarded: Provider = {
276
+ ...resolved.provider,
277
+ complete: async (request) => {
278
+ await admit();
279
+ if (request.signal?.aborted) throw new AiProviderAdmissionError("Request cancelled before dispatch");
280
+ return resolved.provider.complete(request);
281
+ },
282
+ stream: async function* (request) {
283
+ await admit();
284
+ if (request.signal?.aborted) throw new AiProviderAdmissionError("Request cancelled before dispatch");
285
+ yield* resolved.provider.stream(request);
286
+ },
287
+ };
288
+ const provider: Provider = wrapProvider(guarded, resolved.profile, {
289
+ kind: "chat",
290
+ modelRef: resolved.model,
291
+ task: "openai-api",
292
+ subject,
293
+ credentialId: credential.credentialId,
294
+ });
234
295
  const id = `chatcmpl-${crypto.randomUUID()}`;
235
296
  const created = Math.floor(Date.now() / 1000);
236
297
  let events: AsyncIterator<StreamEvent> | undefined;
@@ -284,7 +345,12 @@ export const createAiOpenAiRoutes = (dependencies: Dependencies = {}) => {
284
345
  await events.return?.();
285
346
  }
286
347
  } catch (error) {
287
- if (error instanceof AiQuotaError) throw error;
348
+ if (
349
+ error instanceof AiQuotaError ||
350
+ error instanceof ModelsCredentialRevoked ||
351
+ (error instanceof Error && "aiError" in error)
352
+ )
353
+ throw error;
288
354
  return errorResponse(c, 502, "upstream_error", "api_error");
289
355
  } finally {
290
356
  controller.abort();
@@ -312,7 +378,8 @@ export const createAiOpenAiRoutes = (dependencies: Dependencies = {}) => {
312
378
  } finally {
313
379
  controller.abort();
314
380
  }
315
- if (error instanceof AiQuotaError) throw error;
381
+ if (error instanceof AiQuotaError || error instanceof ModelsCredentialRevoked || (error instanceof Error && "aiError" in error))
382
+ throw error;
316
383
  return errorResponse(c, 502, "upstream_error", "api_error");
317
384
  }
318
385
  const streamEvents = events;
@@ -2,5 +2,5 @@ import type { AiModelPolicy } from "./types";
2
2
 
3
3
  export const personalAiModelPolicy: AiModelPolicy = {
4
4
  kind: "selectable",
5
- requiredCapabilities: ["streaming", "tools"],
5
+ requiredCapabilities: ["tools"],
6
6
  };