@k2b/cloud 0.6.0-rc.0 → 0.7.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 (315) hide show
  1. package/README.md +2 -2
  2. package/package.json +30 -17
  3. package/scripts/browser-performance.ts +14 -0
  4. package/scripts/build-pdf-renderer.ts +39 -0
  5. package/scripts/build.ts +15 -35
  6. package/scripts/preload.ts +9 -12
  7. package/scripts/sync-recovery-smoke.ts +2 -1
  8. package/src/_internal/app-presentation.ts +14 -2
  9. package/src/_internal/capabilities.ts +108 -7
  10. package/src/_internal/capability-streams.ts +81 -0
  11. package/src/_internal/define-app.ts +62 -36
  12. package/src/_internal/heartbeat.ts +4 -0
  13. package/src/_internal/help-catalog.ts +27 -172
  14. package/src/_internal/help.ts +13 -76
  15. package/src/_internal/page-responses.ts +2 -0
  16. package/src/_internal/postgres-application-name.ts +30 -0
  17. package/src/_internal/process-identity.ts +15 -0
  18. package/src/_internal/process-sync.ts +1 -1
  19. package/src/_internal/registry-validation.ts +29 -25
  20. package/src/_internal/registry.ts +2 -53
  21. package/src/_internal/runtime-context.ts +5 -16
  22. package/src/_internal/server-timing.ts +13 -0
  23. package/src/_internal/status-preserving-ssr.ts +18 -6
  24. package/src/_internal/web-vitals-asset.ts +10 -0
  25. package/src/access/PermissionEditor.tsx +14 -153
  26. package/src/access/PrincipalPicker.tsx +104 -0
  27. package/src/access/ui.ts +2 -0
  28. package/src/ai/admin.ts +14 -13
  29. package/src/ai/assistant-models.ts +12 -1
  30. package/src/ai/audio-format.ts +34 -0
  31. package/src/ai/audio-tool.ts +90 -0
  32. package/src/ai/browser-code-contracts.ts +79 -0
  33. package/src/ai/browser.ts +2 -0
  34. package/src/ai/capabilities.ts +55 -36
  35. package/src/ai/capability-execution.ts +47 -12
  36. package/src/ai/chat/blocks.tsx +157 -95
  37. package/src/ai/chat/capability-result.ts +15 -0
  38. package/src/ai/chat/capability-table.tsx +2 -10
  39. package/src/ai/chat/composer-adapter.ts +48 -3
  40. package/src/ai/chat/message-actions.tsx +8 -7
  41. package/src/ai/chat/message-utils.ts +12 -1
  42. package/src/ai/chat/messages.ts +2 -0
  43. package/src/ai/chat/presentation.tsx +19 -48
  44. package/src/ai/chat/primitives.tsx +43 -1
  45. package/src/ai/chat/tool-disclosure.tsx +17 -2
  46. package/src/ai/chat/tool-groups.ts +52 -0
  47. package/src/ai/chat-quotas.ts +25 -0
  48. package/src/ai/chat-task-contracts.ts +9 -1
  49. package/src/ai/chat-tasks.ts +276 -43
  50. package/src/ai/client/controller.ts +110 -33
  51. package/src/ai/client/file-source.ts +12 -6
  52. package/src/ai/client/live-connection.ts +1 -0
  53. package/src/ai/client/projection.ts +22 -11
  54. package/src/ai/client/transport.ts +4 -0
  55. package/src/ai/code-capability-routes.ts +110 -0
  56. package/src/ai/code-capability-transport.ts +38 -0
  57. package/src/ai/code-mode-skill.ts +114 -0
  58. package/src/ai/code-runtime-tools.ts +166 -0
  59. package/src/ai/code-source-contracts.ts +202 -0
  60. package/src/ai/code-source-tools.ts +145 -0
  61. package/src/ai/compaction.ts +27 -6
  62. package/src/ai/data-analysis-skill.ts +10 -0
  63. package/src/ai/default-tools.ts +25 -2
  64. package/src/ai/dictation-runtime.ts +171 -0
  65. package/src/ai/dictations.ts +188 -0
  66. package/src/ai/draft-content.ts +34 -0
  67. package/src/ai/enrich.ts +6 -0
  68. package/src/ai/executor.ts +191 -31
  69. package/src/ai/file-content-version.ts +22 -0
  70. package/src/ai/file-context.ts +2 -1
  71. package/src/ai/file-media-type.ts +12 -0
  72. package/src/ai/file-reference-contracts.ts +15 -0
  73. package/src/ai/file-tools.ts +2 -1
  74. package/src/ai/files-store.ts +174 -71
  75. package/src/ai/fixtures/vision-encrypted.pdf +0 -0
  76. package/src/ai/grids-skill.ts +40 -10
  77. package/src/ai/http.ts +15 -8
  78. package/src/ai/index.ts +26 -2
  79. package/src/ai/inference-calls.ts +198 -0
  80. package/src/ai/live-events.ts +5 -1
  81. package/src/ai/memory-learning.ts +60 -28
  82. package/src/ai/message-queue.ts +167 -0
  83. package/src/ai/migrate.ts +187 -11
  84. package/src/ai/model-pricing.ts +43 -0
  85. package/src/ai/pdf-render-worker.ts +74 -0
  86. package/src/ai/pdf-render.fixture.ts +28 -0
  87. package/src/ai/pdf-render.ts +77 -0
  88. package/src/ai/projects-routes.ts +3 -3
  89. package/src/ai/projects.ts +20 -6
  90. package/src/ai/protocol.ts +3 -0
  91. package/src/ai/provider.ts +1 -8
  92. package/src/ai/quota-provider.ts +191 -0
  93. package/src/ai/quota-report.ts +152 -0
  94. package/src/ai/quotas-migrate.ts +45 -0
  95. package/src/ai/quotas.ts +168 -0
  96. package/src/ai/routes.ts +215 -11
  97. package/src/ai/run-timeout.ts +10 -0
  98. package/src/ai/runtime-tools.ts +3 -0
  99. package/src/ai/runtime.ts +55 -16
  100. package/src/ai/settings.ts +36 -8
  101. package/src/ai/short-id.ts +2 -2
  102. package/src/ai/skill-catalog.ts +0 -10
  103. package/src/ai/skill-search.ts +47 -0
  104. package/src/ai/skill-seeds.ts +105 -20
  105. package/src/ai/skill-tool.ts +16 -5
  106. package/src/ai/skills-routes.ts +32 -4
  107. package/src/ai/skills.ts +324 -155
  108. package/src/ai/store.ts +373 -191
  109. package/src/ai/stream.ts +5 -0
  110. package/src/ai/structured-runs.ts +4 -3
  111. package/src/ai/structured.ts +8 -1
  112. package/src/ai/system-prompt.ts +12 -2
  113. package/src/ai/timeline.ts +2 -5
  114. package/src/ai/todo-contracts.ts +20 -0
  115. package/src/ai/todo-tool.ts +16 -0
  116. package/src/ai/tool-audit.ts +30 -40
  117. package/src/ai/tools.ts +6 -0
  118. package/src/ai/transcription.ts +202 -0
  119. package/src/ai/turn-timing.ts +114 -0
  120. package/src/ai/types.ts +58 -10
  121. package/src/ai/ui.tsx +2 -0
  122. package/src/ai/usage.ts +118 -118
  123. package/src/ai/vision-tool.ts +39 -10
  124. package/src/api/admin-ai-projects.ts +2 -2
  125. package/src/api/admin-ai-quotas.ts +74 -0
  126. package/src/api/admin-ai-skills.ts +48 -5
  127. package/src/api/admin-ai-usage.ts +2 -2
  128. package/src/api/admin-core-settings.ts +25 -0
  129. package/src/api/admin-identity.ts +91 -51
  130. package/src/api/admin-rail.ts +58 -0
  131. package/src/api/announcements.ts +14 -0
  132. package/src/api/capabilities.ts +292 -43
  133. package/src/api/capability-streams.ts +262 -0
  134. package/src/api/help.ts +8 -44
  135. package/src/api/index.ts +6 -0
  136. package/src/api/mcp.ts +44 -26
  137. package/src/api/me-web-vitals.ts +35 -0
  138. package/src/api/me.ts +2 -0
  139. package/src/api/search/schemas.ts +26 -15
  140. package/src/api/search.bench.ts +1 -1
  141. package/src/api/search.ts +54 -17
  142. package/src/browser/CloudResourceSearch.tsx +640 -702
  143. package/src/browser/app-approval-vault.ts +19 -7
  144. package/src/browser/command-bridge.ts +109 -0
  145. package/src/browser/command-shortcuts.ts +76 -0
  146. package/src/browser/commands.ts +66 -0
  147. package/src/browser/navigation-search.ts +48 -0
  148. package/src/browser/notifications.ts +1 -1
  149. package/src/browser/resource-picker.tsx +5 -18
  150. package/src/browser/resource-search-dialog.ts +9 -0
  151. package/src/browser/resource-search-input.ts +46 -0
  152. package/src/browser/resource-search-messages.ts +76 -40
  153. package/src/browser/resource-search.ts +7 -0
  154. package/src/browser/search-bridge.ts +102 -0
  155. package/src/browser/search-commands.ts +59 -0
  156. package/src/browser/search.ts +13 -0
  157. package/src/browser/spotlight-position.ts +208 -0
  158. package/src/browser/testing.ts +5 -0
  159. package/src/browser/web-vitals.ts +25 -0
  160. package/src/capabilities/claims.ts +179 -0
  161. package/src/capabilities/client.ts +34 -0
  162. package/src/capabilities/command-link.ts +16 -0
  163. package/src/capabilities/executions.ts +298 -0
  164. package/src/capabilities/migrate.ts +81 -0
  165. package/src/capabilities/server.ts +114 -34
  166. package/src/capabilities/stream-body.ts +16 -0
  167. package/src/capabilities/streams.ts +28 -0
  168. package/src/cli/admin/ai-quotas.ts +161 -0
  169. package/src/cli/admin/ai-skills.ts +64 -0
  170. package/src/cli/admin/ai-usage.ts +53 -30
  171. package/src/cli/admin/app-credentials.ts +79 -0
  172. package/src/cli/admin/data.ts +17 -0
  173. package/src/cli/admin/index.ts +9 -0
  174. package/src/cli/capabilities.ts +29 -0
  175. package/src/cli/capability-streams.ts +25 -0
  176. package/src/cli/index.ts +3 -2
  177. package/src/config/env.ts +11 -8
  178. package/src/contracts/app.ts +23 -3
  179. package/src/contracts/capabilities.ts +68 -5
  180. package/src/contracts/capability-streams.ts +46 -0
  181. package/src/contracts/commands.ts +49 -0
  182. package/src/contracts/index.ts +6 -1
  183. package/src/contracts/rail-admin.ts +38 -0
  184. package/src/contracts/rail-preferences.ts +16 -0
  185. package/src/contracts/registry.ts +3 -27
  186. package/src/contracts/settings-types.ts +1 -1
  187. package/src/contracts/shared.ts +9 -5
  188. package/src/contracts/web-vitals.ts +27 -0
  189. package/src/desktop/solid.tsx +3 -3
  190. package/src/index.ts +0 -4
  191. package/src/server/index.ts +3 -0
  192. package/src/server/middleware/auth.ts +3 -2
  193. package/src/server/middleware/route-template.ts +9 -3
  194. package/src/server/middleware/runtime.ts +2 -1
  195. package/src/server/middleware/settings.ts +17 -11
  196. package/src/server/services/access-revision.ts +10 -0
  197. package/src/server/services/access.ts +1 -1
  198. package/src/server/services/index.ts +1 -0
  199. package/src/services/account-category-policy.ts +9 -0
  200. package/src/services/account-lifecycle/audit.ts +1 -1
  201. package/src/services/accounts/app.ts +12 -2
  202. package/src/services/accounts/email-write.ts +23 -0
  203. package/src/services/accounts/groups.ts +15 -4
  204. package/src/services/accounts/identities.ts +249 -0
  205. package/src/services/accounts/identity-reconciliation.ts +87 -0
  206. package/src/services/accounts/ipa-data.ts +2 -2
  207. package/src/services/accounts/local-groups.ts +2 -2
  208. package/src/services/accounts/posix.ts +45 -29
  209. package/src/services/accounts/users.ts +2 -2
  210. package/src/services/announcements/index.ts +69 -8
  211. package/src/services/audit/index.ts +1 -1
  212. package/src/services/cache-fill.ts +35 -0
  213. package/src/services/help/index.ts +109 -0
  214. package/src/services/help/maintenance.ts +25 -0
  215. package/src/services/help/store.ts +180 -0
  216. package/src/services/help/types.ts +46 -0
  217. package/src/services/identity/invocation-actor.ts +2 -2
  218. package/src/services/identity/key-config.ts +2 -1
  219. package/src/services/identity/key-ring.ts +1 -1
  220. package/src/services/index.ts +15 -0
  221. package/src/services/ipa/users.ts +268 -221
  222. package/src/services/legal-consent.ts +1 -1
  223. package/src/services/logging/index.ts +15 -13
  224. package/src/services/logging/trace.ts +2 -1
  225. package/src/services/logging/web-vitals.ts +81 -0
  226. package/src/services/mandates/index.ts +18 -4
  227. package/src/services/mandates/policy.ts +65 -2
  228. package/src/services/notifications/batches.ts +124 -57
  229. package/src/services/notifications/browser.ts +12 -0
  230. package/src/services/notifications/index.ts +0 -2
  231. package/src/services/notifications/platform.ts +0 -4
  232. package/src/services/oauth-tokens.ts +3 -3
  233. package/src/services/pdf/gotenberg.ts +107 -84
  234. package/src/services/pdf/index.ts +3 -0
  235. package/src/services/providers/local/users.ts +25 -16
  236. package/src/services/public-http.ts +101 -0
  237. package/src/services/rail-shortcuts.ts +95 -0
  238. package/src/services/rail-snapshot.ts +53 -0
  239. package/src/services/request-cache-redis.ts +28 -0
  240. package/src/services/session/index.ts +15 -2
  241. package/src/services/session/user.ts +62 -37
  242. package/src/services/settings/core-settings.ts +21 -28
  243. package/src/services/settings/defaults.ts +5 -0
  244. package/src/services/settings/store.ts +58 -84
  245. package/src/shared/ai-costs.ts +42 -0
  246. package/src/shared/ai-platform-prompt.ts +1 -1
  247. package/src/shared/ai-quotas.ts +136 -0
  248. package/src/shared/ai-usage.ts +6 -16
  249. package/src/shared/app-presentation.ts +21 -15
  250. package/src/shared/capability-messages.ts +14 -0
  251. package/src/shared/help.ts +6 -42
  252. package/src/shared/index.ts +6 -0
  253. package/src/ssr/AdminLayout.tsx +7 -5
  254. package/src/ssr/AdminSidebar.tsx +29 -15
  255. package/src/ssr/AppLaunchpad.island.tsx +24 -137
  256. package/src/ssr/AppLaunchpadPanel.tsx +181 -0
  257. package/src/ssr/BrowserPushRegistration.island.tsx +12 -0
  258. package/src/ssr/GlobalSearchDialog.tsx +167 -20
  259. package/src/ssr/GlobalSearchTrigger.island.tsx +33 -22
  260. package/src/ssr/Layout.tsx +17 -6
  261. package/src/ssr/LayoutBreadcrumbs.island.tsx +3 -5
  262. package/src/ssr/LayoutFooter.tsx +1 -1
  263. package/src/ssr/LayoutHeader.tsx +46 -14
  264. package/src/ssr/LayoutHelp.tsx +23 -22
  265. package/src/ssr/{HotkeysHelpRail.island.tsx → LayoutHelpTrigger.island.tsx} +18 -12
  266. package/src/ssr/LayoutRail.tsx +17 -9
  267. package/src/ssr/MobileNavigation.tsx +71 -0
  268. package/src/ssr/MobileProfileActions.tsx +49 -0
  269. package/src/ssr/ProfilePreferences.island.tsx +3 -36
  270. package/src/ssr/RailApps.island.tsx +2 -2
  271. package/src/ssr/RailEditor.tsx +17 -2
  272. package/src/ssr/WorkspaceNavigation.island.tsx +7 -0
  273. package/src/ssr/WorkspaceNavigationProvider.tsx +16 -0
  274. package/src/ssr/admin-navigation.ts +7 -1
  275. package/src/ssr/help.ts +29 -0
  276. package/src/ssr/islands/SearchBar.tsx +67 -0
  277. package/src/ssr/islands/index.ts +3 -1
  278. package/src/ssr/layout-context.ts +2 -2
  279. package/src/ssr/layout-help-search.ts +15 -0
  280. package/src/ssr/mobile-menu-history.ts +70 -0
  281. package/src/ssr/platform-messages.ts +16 -8
  282. package/src/ssr/profile-actions.ts +42 -0
  283. package/src/ssr/rail-context.ts +8 -4
  284. package/src/ssr/rail-messages.ts +2 -0
  285. package/src/ssr/rail-navigation.ts +3 -3
  286. package/src/ssr/workspace-navigation.ts +75 -0
  287. package/src/styles/effects.css +6 -6
  288. package/src/styles/global.css +54 -0
  289. package/src/styles/input.css +43 -41
  290. package/src/styles/resource-search.css +505 -0
  291. package/src/styles/utilities-feedback.css +13 -0
  292. package/src/styles/utilities-layout.css +1 -5
  293. package/src/styles/utilities-navigation.css +3 -39
  294. package/src/types/ambient.d.ts +5 -0
  295. package/src/workflows/ai/runtime.ts +5 -1
  296. package/src/workflows/ai/store.ts +2 -0
  297. package/src/workflows/ai-actions.ts +16 -13
  298. package/src/workflows/definition.ts +10 -5
  299. package/src/workflows/store/actions.ts +44 -20
  300. package/src/workflows/store/index.ts +2 -0
  301. package/src/workflows/store/runs.ts +36 -0
  302. package/src/workflows/store/worker-pool.ts +55 -0
  303. package/src/workflows/store/worker-runtime.ts +90 -0
  304. package/scripts/app-favicon.test.ts +0 -49
  305. package/src/ai/chat/blocks.render.test.tsx +0 -1108
  306. package/src/ai/kit-skill.ts +0 -22
  307. package/src/ai/usage-migrate.ts +0 -81
  308. package/src/browser/CloudResourceSearch.behavior.test.tsx +0 -74
  309. package/src/browser/notification-target.ts +0 -27
  310. package/src/contracts/notification-live.ts +0 -66
  311. package/src/services/notifications/live.ts +0 -50
  312. package/src/ssr/BrowserNotifications.island.tsx +0 -135
  313. package/src/ssr/GlobalSearchDialog.behavior.test.tsx +0 -96
  314. package/src/ssr/Layout.render.test.tsx +0 -148
  315. package/src/ssr/islands/SearchBar.island.tsx +0 -80
@@ -10,12 +10,11 @@ const commonOptions = (profile: AiModelProfile, apiKey?: string) => ({
10
10
  baseURL: profile.baseURL,
11
11
  contextWindow: profile.contextWindow,
12
12
  temperature: profile.temperature,
13
- creditsPerInputToken: profile.creditsPerInputToken,
14
- creditsPerOutputToken: profile.creditsPerOutputToken,
15
13
  timeouts: PROVIDER_TIMEOUTS,
16
14
  });
17
15
 
18
16
  export const createAiProvider = (profile: AiModelProfile, apiKey?: string): Provider => {
17
+ if (profile.capabilities.includes("transcription")) throw new Error("Transcription profiles cannot be used for chat generation.");
19
18
  switch (profile.provider) {
20
19
  case "openai":
21
20
  return openai(profile.model, commonOptions(profile, apiKey));
@@ -32,8 +31,6 @@ export const createAiProvider = (profile: AiModelProfile, apiKey?: string): Prov
32
31
  baseURL: profile.baseURL,
33
32
  contextWindow: profile.contextWindow,
34
33
  temperature: profile.temperature,
35
- creditsPerInputToken: profile.creditsPerInputToken,
36
- creditsPerOutputToken: profile.creditsPerOutputToken,
37
34
  timeouts: PROVIDER_TIMEOUTS,
38
35
  });
39
36
  case "vllm":
@@ -44,8 +41,6 @@ export const createAiProvider = (profile: AiModelProfile, apiKey?: string): Prov
44
41
  apiKey,
45
42
  contextWindow: profile.contextWindow,
46
43
  temperature: profile.temperature,
47
- creditsPerInputToken: profile.creditsPerInputToken,
48
- creditsPerOutputToken: profile.creditsPerOutputToken,
49
44
  timeouts: PROVIDER_TIMEOUTS,
50
45
  compat: {
51
46
  toolCallIdPolicy: "passthrough",
@@ -65,8 +60,6 @@ export const createAiProvider = (profile: AiModelProfile, apiKey?: string): Prov
65
60
  apiKey,
66
61
  contextWindow: profile.contextWindow,
67
62
  temperature: profile.temperature,
68
- creditsPerInputToken: profile.creditsPerInputToken,
69
- creditsPerOutputToken: profile.creditsPerOutputToken,
70
63
  timeouts: PROVIDER_TIMEOUTS,
71
64
  });
72
65
  }
@@ -0,0 +1,191 @@
1
+ import { isAssistantChatTurn } from "./assistant-models";
2
+ import { estimateTokens, type Provider } from "@k2b/nessi";
3
+ import type { AccessSubject } from "../server/services/access";
4
+ import { sql } from "bun";
5
+ import { setTimeout as delay } from "node:timers/promises";
6
+ import { AiBackgroundAdmissionError, beginAiCall, finishAiCall, type AiCallContext } from "./inference-calls";
7
+ import type { AiModelProfile } from "./types";
8
+ import { logger } from "../services/logging";
9
+ const log = logger("ai:quotas");
10
+
11
+ /** One wrapper owns call accounting. complete() can have a separate compaction purpose. */
12
+ const callLifecycle = {
13
+ begin: beginAiCall,
14
+ finish: finishAiCall,
15
+ heartbeat: async (id: string) => {
16
+ await sql`UPDATE ai.inference_calls SET lease_expires_at=now()+interval '2 minutes' WHERE id=${id}::uuid AND finished_at IS NULL`;
17
+ },
18
+ };
19
+ export function inferenceProvider(
20
+ provider: Provider,
21
+ profile: AiModelProfile,
22
+ context: AiCallContext,
23
+ completeContext = context,
24
+ lifecycle = callLifecycle,
25
+ ): Provider {
26
+ const begin = async (request: Parameters<Provider["complete"]>[0], ctx: AiCallContext) => {
27
+ const messages = request.messages.map((message) =>
28
+ message.role === "user"
29
+ ? {
30
+ ...message,
31
+ content: message.content.filter((part) => typeof part === "string" || part.type !== "file"),
32
+ }
33
+ : message,
34
+ );
35
+ const inputTokens =
36
+ estimateTokens(messages) +
37
+ Math.ceil(
38
+ ((request.systemPrompt?.length ?? 0) +
39
+ JSON.stringify(request.tools ?? []).length +
40
+ JSON.stringify(request.responseFormat ?? {}).length) /
41
+ 4,
42
+ );
43
+ // Wait up to one reservation lease before delegating to the caller's retry policy.
44
+ // No provider request or new reservation exists while waiting; cancellation stays active.
45
+ const deadline = Date.now() + 120_000;
46
+ const admit = async () => {
47
+ for (;;) {
48
+ request.signal?.throwIfAborted();
49
+ try {
50
+ return await lifecycle.begin(
51
+ profile,
52
+ ctx,
53
+ inputTokens,
54
+ request.maxOutputTokens ?? profile.maxOutputTokens,
55
+ // Nessi's Anthropic adapter defaults to 1024; other adapters use the model default.
56
+ provider.family === "anthropic" ? 1024 : provider.contextWindow,
57
+ );
58
+ } catch (error) {
59
+ if (!(error instanceof AiBackgroundAdmissionError) || !error.retryable || Date.now() >= deadline) throw error;
60
+ await delay(1_000, undefined, { signal: request.signal });
61
+ }
62
+ }
63
+ };
64
+ const call = await admit();
65
+ const heartbeat = setInterval(() => {
66
+ void lifecycle.heartbeat(call.id).catch(() => log.warn("AI accounting heartbeat failed", { callId: call.id }));
67
+ }, 30_000);
68
+ return { ...call, inputTokens, stop: () => clearInterval(heartbeat) };
69
+ };
70
+ const finish = async (id: string, usage: Parameters<typeof finishAiCall>[1], status: "ok" | "failed") => {
71
+ try {
72
+ await lifecycle.finish(id, usage, status);
73
+ } catch {
74
+ log.error("AI cost booking failed", { callId: id, code: "ai_cost_booking_failed" });
75
+ }
76
+ };
77
+ return {
78
+ name: provider.name,
79
+ family: provider.family,
80
+ model: provider.model,
81
+ contextWindow: provider.contextWindow,
82
+ capabilities: provider.capabilities,
83
+ complete: async (request) => {
84
+ const call = await begin(request, completeContext);
85
+ let usage: Parameters<typeof finishAiCall>[1];
86
+ let status: "ok" | "failed" = "failed";
87
+ try {
88
+ const result = await provider.complete({ ...request, maxOutputTokens: call.maxOutputTokens });
89
+ if (
90
+ result.usage &&
91
+ [result.usage.input, result.usage.output].every((n) => Number.isSafeInteger(n) && n >= 0) &&
92
+ result.usage.input + result.usage.output > 0
93
+ )
94
+ usage = { input: result.usage.input, output: result.usage.output };
95
+ status = ["error", "aborted", "interrupted"].includes(result.finishReason) ? "failed" : "ok";
96
+ return result;
97
+ } finally {
98
+ call.stop();
99
+ if (!usage && status === "failed") usage = { input: call.inputTokens, output: 0, estimated: true };
100
+ await finish(call.id, usage, status);
101
+ }
102
+ },
103
+ stream: async function* (request) {
104
+ const call = await begin(request, context);
105
+ const id = call.id;
106
+ let usage: { input: number; output: number; estimated?: boolean } | undefined;
107
+ let completed = false;
108
+ let failed = false;
109
+ const outputBlocks = new Map<string, number>();
110
+ let generated = false;
111
+ try {
112
+ for await (const event of provider.stream({ ...request, maxOutputTokens: call.maxOutputTokens })) {
113
+ if (event.type === "issue" && event.issue.kind === "provider_error") failed = true;
114
+ if (event.type === "block_delta") outputBlocks.set(event.blockId, (outputBlocks.get(event.blockId) ?? 0) + event.delta.length);
115
+ if (event.type === "block_end") {
116
+ const block = event.block;
117
+ const size =
118
+ block.type === "text"
119
+ ? block.text.length
120
+ : block.type === "thinking"
121
+ ? block.thinking.length
122
+ : block.name.length + JSON.stringify(block.args).length;
123
+ outputBlocks.set(event.blockId, size);
124
+ }
125
+ if (event.type === "block_start" || event.type === "block_delta" || event.type === "block_end") generated = true;
126
+ if (event.type === "issue" && event.issue.kind === "provider_error" && event.issue.contextOverflow && !generated && !usage)
127
+ usage = { input: 0, output: 0 };
128
+ if (event.type === "usage") {
129
+ if (event.finishReason === "aborted" || event.finishReason === "interrupted" || event.finishReason === "error") failed = true;
130
+ const { input, output } = event.usage;
131
+ // Some adapters synthesize all-zero usage when the endpoint reports none.
132
+ if (Number.isSafeInteger(input) && Number.isSafeInteger(output) && input >= 0 && output >= 0 && input + output > 0)
133
+ usage = { input, output };
134
+ }
135
+ yield event;
136
+ }
137
+ completed = true;
138
+ } finally {
139
+ call.stop();
140
+ if (id) {
141
+ try {
142
+ // Interrupted adapters commonly omit their final usage event. Charge an
143
+ // explicitly labelled estimate, never invent a measured zero.
144
+ const interrupted = !completed || failed || request.signal?.aborted;
145
+ if (!usage && interrupted) {
146
+ // Encoded files are not text tokens. Their provider-specific costs
147
+ // cannot be reconstructed after interruption.
148
+ const messages = request.messages.map((message) =>
149
+ message.role === "user"
150
+ ? {
151
+ ...message,
152
+ content: message.content.filter((part) => typeof part === "string" || part.type !== "file"),
153
+ }
154
+ : message,
155
+ );
156
+ const instructionSize =
157
+ (request.systemPrompt?.length ?? 0) +
158
+ JSON.stringify(request.tools ?? []).length +
159
+ JSON.stringify(request.responseFormat ?? {}).length;
160
+ usage = {
161
+ input: estimateTokens(messages) + Math.ceil(instructionSize / 4),
162
+ output: Math.ceil(Array.from(outputBlocks.values()).reduce((sum, size) => sum + size, 0) / 4),
163
+ estimated: true,
164
+ };
165
+ }
166
+ await finish(id, usage, completed && !failed && !request.signal?.aborted ? "ok" : "failed");
167
+ } catch (error) {
168
+ log.warn("Chat usage booking failed", { code: "quota_booking_failed", turnId: context.turnId, callId: id });
169
+ }
170
+ }
171
+ }
172
+ },
173
+ };
174
+ }
175
+
176
+ export function assistantQuotaProvider(
177
+ provider: Provider,
178
+ config: Parameters<typeof isAssistantChatTurn>[0],
179
+ subject: AccessSubject | null,
180
+ profile: AiModelProfile,
181
+ turnId: string,
182
+ conversationId: string,
183
+ ) {
184
+ const common = { subject, turnId, conversationId };
185
+ return inferenceProvider(
186
+ provider,
187
+ profile,
188
+ { ...common, kind: isAssistantChatTurn(config) ? "chat" : "background", task: isAssistantChatTurn(config) ? "chat" : "agent-turn" },
189
+ { ...common, kind: "background", task: "chat-compaction" },
190
+ );
191
+ }
@@ -0,0 +1,152 @@
1
+ import { hasBillableAiPricing } from "../shared/ai-costs";
2
+ import { readAiSettingsState } from "./settings";
3
+ import { toPgTextArray } from "../services/postgres";
4
+ import { sql } from "bun";
5
+ import { recursiveGroupIdsSubquery } from "../services/accounts/group-sql";
6
+ import { AiQuotaReportQuerySchema, type AiQuotaReport, type AiQuotaReportQuery } from "../shared/ai-quotas";
7
+ import { resolveDisplayNames } from "../server/services/access";
8
+ import { aiQuotas } from "./quotas";
9
+
10
+ /** Resolve display metadata only for administration, never on the inference path. */
11
+ export async function quotaAdminConfig() {
12
+ const config = await aiQuotas.config();
13
+ const grants = await resolveDisplayNames(config.rules.flatMap((rule) => rule.grants));
14
+ let offset = 0;
15
+ return {
16
+ ...config,
17
+ rules: config.rules.map((rule) => ({
18
+ ...rule,
19
+ grants: rule.grants.map(() => {
20
+ const grant = grants[offset++]!;
21
+ return { principal: grant.principal, limit: grant.limit, displayName: grant.displayName };
22
+ }),
23
+ })),
24
+ };
25
+ }
26
+
27
+ /** One database snapshot: aggregate and filter the cohort before paginating identities. */
28
+ export async function quotaReport(input: Partial<AiQuotaReportQuery> = {}, now = new Date()): Promise<AiQuotaReport> {
29
+ const query = AiQuotaReportQuerySchema.parse(input);
30
+ const until = new Date(Math.min(Date.parse(query.until ?? now.toISOString()), now.getTime()));
31
+ const days = { "24h": 1, "7d": 7, "30d": 30, "90d": 90 }[query.range];
32
+ const since = new Date(until.getTime() - days * 86400000);
33
+ if (query.view === "rules")
34
+ return {
35
+ query,
36
+ since: since.toISOString(),
37
+ until: until.toISOString(),
38
+ asOf: now.toISOString(),
39
+ overview: { cost: null, accounts: 0, input: 0, output: 0, calls: 0, measured: 0, estimated: 0, unknown: 0 },
40
+ timeline: [],
41
+ models: [],
42
+ items: [],
43
+ selected: null,
44
+ total: 0,
45
+ page: 1,
46
+ perPage: 25,
47
+ };
48
+ const pricedModels = (await readAiSettingsState()).profiles.filter((p) => hasBillableAiPricing(p.pricing)).map((p) => p.id);
49
+ const bucket = query.range === "24h" ? "hour" : "day";
50
+ const order = query.sort === "label" ? sql`label` : query.sort === "cost" ? sql`cost` : sql`last_used`;
51
+ const direction = query.direction === "asc" ? sql`ASC` : sql`DESC`;
52
+ // JSON grant cardinality is unknown to the planner; JIT compilation otherwise
53
+ // dominates small admin reports. Scope this setting to this read transaction.
54
+ const [data] = await sql.begin(async (db) => {
55
+ await db`SET LOCAL jit = off`;
56
+ return db<Omit<AiQuotaReport, "query" | "since" | "until" | "asOf">[]>`
57
+ WITH config AS MATERIALIZED (SELECT enabled,rules FROM ai.cost_config WHERE singleton),
58
+ rules AS MATERIALIZED (
59
+ SELECT r->>'scope' AS scope,r->'grants' AS grants,
60
+ (r->>'anchor')::timestamptz + floor(extract(epoch FROM (${now}::timestamptz-(r->>'anchor')::timestamptz)) / ((r->>'hours')::int*3600)) * ((r->>'hours')::int*interval '1 hour') AS starts
61
+ FROM config,jsonb_array_elements(rules) r WHERE (${query.model}='' OR (r->>'scope' IN ('*',${query.model}) AND ${query.model}=ANY(${toPgTextArray(pricedModels)}::text[]))) AND (r->>'scope'='*' OR r->>'scope'=ANY(${toPgTextArray(pricedModels)}::text[]))
62
+ ), accounts AS (
63
+ SELECT 'user'::text AS type,u.id,COALESCE(NULLIF(u.display_name,''),u.uid) AS label,u.id AS user_id,NULL::uuid AS service_id
64
+ FROM auth.users u WHERE ${query.search}<>'' OR EXISTS(SELECT 1 FROM ai.inference_calls WHERE kind='chat' AND user_id=u.id)
65
+ OR EXISTS(SELECT 1 FROM ai.conversations c JOIN ai.turns t ON t.conversation_id=c.id WHERE c.created_by_user_id=u.id AND t.run_config->>'assistantChat'='true' AND t.run_config->'mandate' IS NULL)
66
+ UNION ALL SELECT 'service_account',id,name,NULL::uuid,id FROM auth.service_accounts s
67
+ WHERE ${query.search}<>'' OR EXISTS(SELECT 1 FROM ai.inference_calls WHERE kind='chat' AND service_account_id=s.id)
68
+ ), identities AS MATERIALIZED (
69
+ SELECT a.*,ARRAY(SELECT group_id FROM (${recursiveGroupIdsSubquery(sql`a.user_id`)}) groups) AS groups
70
+ FROM accounts a WHERE position(lower(${query.search}) in lower(label))>0 OR id::text=${query.search}
71
+ ), calls AS MATERIALIZED (
72
+ SELECT c.*,COALESCE(c.user_id,c.service_account_id) AS identity_id,
73
+ CASE WHEN c.user_id IS NOT NULL THEN 'user' ELSE 'service_account' END AS identity_type,
74
+ (COALESCE((c.pricing->>'inputPerMillion')::numeric,0)>0 OR COALESCE((c.pricing->>'outputPerMillion')::numeric,0)>0) AND c.cost IS NULL AND (c.finished_at IS NOT NULL OR (c.lease_expires_at<=${now} OR (c.turn_id IS NOT NULL AND NOT EXISTS(SELECT 1 FROM ai.turns t WHERE t.id=c.turn_id AND t.attempt=c.turn_attempt AND t.status='running' AND t.lease_expires_at>${now})))) AS unknown
75
+ FROM ai.inference_calls c WHERE c.kind='chat' AND c.started_at<=${now}
76
+ ), periods AS MATERIALIZED (
77
+ SELECT * FROM calls WHERE started_at>=${since} AND started_at<${until}
78
+ AND (${query.model}='' OR model_profile_id=${query.model})
79
+ ), allowances AS MATERIALIZED (
80
+ SELECT a.id,a.type,r.scope,
81
+ 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,
82
+ r.starts
83
+ FROM identities a CROSS JOIN rules r
84
+ LEFT JOIN LATERAL (SELECT g FROM jsonb_array_elements(r.grants) g WHERE
85
+ g->'principal'->>'type'='authenticated'
86
+ OR (g->'principal'->>'userId')::uuid=a.user_id
87
+ OR (g->'principal'->>'serviceAccountId')::uuid=a.service_id
88
+ OR (g->'principal'->>'groupId')::uuid=ANY(a.groups)) grant_match ON true
89
+ GROUP BY a.id,a.type,r.scope,r.starts
90
+ ), balances AS MATERIALIZED (
91
+ SELECT a.*,COALESCE(sum(c.cost),0)::float8 AS used,count(c.id) FILTER(WHERE c.unknown)::int AS unknown
92
+ FROM allowances a LEFT JOIN calls c ON c.identity_id=a.id AND c.identity_type=a.type
93
+ AND (a.scope='*' OR c.model_profile_id=a.scope) AND c.started_at>=a.starts
94
+ AND c.started_at>COALESCE((SELECT max(created_at) FROM ai.cost_resets z
95
+ WHERE COALESCE(z.user_id,z.service_account_id)=a.id AND (z.user_id IS NOT NULL)=(a.type='user') AND z.scope=a.scope),'-infinity'::timestamptz)
96
+ GROUP BY a.id,a.type,a.scope,a.amount,a.starts
97
+ ), states AS MATERIALIZED (
98
+ SELECT id,type,count(*)::int AS scopes,
99
+ count(*) FILTER(WHERE amount IS NOT NULL AND used>=amount)::int AS exhausted,
100
+ CASE WHEN bool_or(scope='*' AND amount IS NULL) THEN 'unlimited'
101
+ WHEN bool_or(amount IS NOT NULL AND unknown>0) THEN 'unknown'
102
+ WHEN bool_or(amount IS NOT NULL AND used>=amount) THEN 'exhausted'
103
+ WHEN bool_or(amount IS NOT NULL) THEN 'available' ELSE 'unlimited' END AS status
104
+ FROM balances GROUP BY id,type
105
+ ), summary AS MATERIALIZED (
106
+ SELECT a.id,a.type,a.label,
107
+ CASE WHEN NOT COALESCE((SELECT enabled FROM config),false) THEN 'disabled' ELSE COALESCE(s.status,'unlimited') END AS status,
108
+ COALESCE(s.scopes,0) AS scopes,CASE WHEN s.status='unlimited' OR NOT COALESCE((SELECT enabled FROM config),false) THEN 0 ELSE COALESCE(s.exhausted,0) END AS exhausted,
109
+ (SELECT max(started_at) FROM calls c WHERE c.identity_id=a.id AND c.identity_type=a.type AND (${query.model}='' OR c.model_profile_id=${query.model})) AS last_used,
110
+ sum(p.cost)::float8 AS cost,COALESCE(sum(p.input),0)::float8 AS input,COALESCE(sum(p.output),0)::float8 AS output,
111
+ count(p.id)::int AS calls,count(p.id) FILTER(WHERE p.input IS NOT NULL AND p.output IS NOT NULL)::int AS measured,count(p.id) FILTER(WHERE p.estimated)::int AS estimated,count(p.id) FILTER(WHERE p.unknown)::int AS unknown
112
+ FROM identities a LEFT JOIN states s ON s.id=a.id AND s.type=a.type
113
+ LEFT JOIN periods p ON p.identity_id=a.id AND p.identity_type=a.type
114
+ GROUP BY a.id,a.type,a.label,s.status,s.scopes,s.exhausted
115
+ ), cohort AS MATERIALIZED (
116
+ SELECT * FROM summary WHERE ${query.status}='all' OR status=${query.status}
117
+ ), selected_calls AS (
118
+ SELECT p.* FROM periods p JOIN cohort a ON a.id=p.identity_id AND a.type=p.identity_type
119
+ ), pagination AS (
120
+ SELECT count(*)::int AS total,least(${query.page},greatest(1,ceil(count(*)/25.0)))::int AS page,25 AS "perPage" FROM cohort
121
+ ), page_rows AS (
122
+ SELECT id,type,label,cost,input,output,calls,measured,estimated,unknown,status,scopes,exhausted,
123
+ COALESCE((SELECT jsonb_agg(jsonb_build_object('scope',b.scope,'limit',b.amount,'used',b.used,'unknown',b.unknown,
124
+ 'bypassed', NOT COALESCE((SELECT enabled FROM config),false) OR (b.scope<>'*' AND EXISTS(SELECT 1 FROM balances w WHERE w.id=b.id AND w.type=b.type AND w.scope='*' AND w.amount IS NULL))) ORDER BY b.scope)
125
+ FROM balances b WHERE b.id=cohort.id AND b.type=cohort.type),'[]') AS balances,
126
+ to_char(last_used AT TIME ZONE 'UTC','YYYY-MM-DD"T"HH24:MI:SS.MS"Z"') AS "lastUsed"
127
+ FROM cohort ORDER BY ${order} ${direction} NULLS LAST,id,type LIMIT 25 OFFSET ((SELECT page FROM pagination)-1)*25
128
+ ), timeline AS (
129
+ SELECT date_trunc(${bucket},started_at AT TIME ZONE 'UTC') AS bucket,
130
+ sum(cost)::float8 AS cost,COALESCE(sum(input),0)::float8 AS input,COALESCE(sum(output),0)::float8 AS output,count(*)::int AS calls,count(*) FILTER(WHERE input IS NOT NULL AND output IS NOT NULL)::int AS measured,count(*) FILTER(WHERE unknown)::int AS unknown
131
+ FROM selected_calls GROUP BY 1 ORDER BY 1
132
+ ), models AS (
133
+ SELECT model_profile_id AS model,sum(cost)::float8 AS cost,COALESCE(sum(input),0)::float8 AS input,COALESCE(sum(output),0)::float8 AS output,
134
+ count(*)::int AS calls,count(*) FILTER(WHERE input IS NOT NULL AND output IS NOT NULL)::int AS measured,count(*) FILTER(WHERE unknown)::int AS unknown
135
+ FROM selected_calls GROUP BY 1 ORDER BY sum(cost) DESC NULLS LAST,model_profile_id LIMIT 20
136
+ ) SELECT pagination.*,
137
+ (SELECT jsonb_build_object('cost',sum(cost),'accounts',count(*) FILTER(WHERE calls>0),'input',COALESCE(sum(input),0),'output',COALESCE(sum(output),0),
138
+ 'calls',COALESCE(sum(calls),0),'measured',COALESCE(sum(measured),0),'estimated',COALESCE(sum(estimated),0),'unknown',COALESCE(sum(unknown),0)) FROM cohort) AS overview,
139
+ (SELECT jsonb_build_object('id',id,'type',type,'label',label,'lastUsed',NULL) FROM accounts WHERE id=${query.identity ?? null}::uuid AND type=${query.identityType}) AS selected,
140
+ COALESCE((SELECT jsonb_agg(page_rows) FROM page_rows),'[]') AS items,
141
+ COALESCE((SELECT jsonb_agg(jsonb_build_object('cost',cost,'at',to_char(bucket,'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"'),'input',input,'output',output,'calls',calls,'measured',measured,'unknown',unknown)) FROM timeline),'[]') AS timeline,
142
+ COALESCE((SELECT jsonb_agg(models) FROM models),'[]') AS models FROM pagination`;
143
+ });
144
+ if (!data) throw new Error("Quota report returned no snapshot");
145
+ return {
146
+ ...data,
147
+ query: { ...query, until: until.toISOString(), page: data.page },
148
+ since: since.toISOString(),
149
+ until: until.toISOString(),
150
+ asOf: now.toISOString(),
151
+ };
152
+ }
@@ -0,0 +1,45 @@
1
+ import { sql } from "bun";
2
+ export async function migrateAiQuotas() {
3
+ await sql.begin(async (db) => {
4
+ await db`CREATE TABLE IF NOT EXISTS ai.cost_config (
5
+ singleton BOOLEAN PRIMARY KEY DEFAULT true CHECK(singleton), enabled BOOLEAN NOT NULL DEFAULT false,
6
+ revision INTEGER NOT NULL DEFAULT 0, rules JSONB NOT NULL DEFAULT '[]',
7
+ unit TEXT NOT NULL DEFAULT 'EUR', background JSONB, background_stopped_at TIMESTAMPTZ, background_warned BOOLEAN NOT NULL DEFAULT false
8
+ )`;
9
+ await db`CREATE TABLE IF NOT EXISTS ai.cost_alerts (
10
+ id UUID PRIMARY KEY, kind TEXT NOT NULL CHECK(kind IN ('warning','stop')), cost NUMERIC(30,12) NOT NULL,
11
+ unit TEXT NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp()
12
+ )`;
13
+ await db`INSERT INTO ai.cost_config(singleton) VALUES(true) ON CONFLICT DO NOTHING`;
14
+ await db`CREATE TABLE IF NOT EXISTS ai.inference_calls (
15
+ id UUID PRIMARY KEY, user_id UUID REFERENCES auth.users(id) ON DELETE SET NULL,
16
+ service_account_id UUID REFERENCES auth.service_accounts(id) ON DELETE SET NULL,
17
+ model_profile_id TEXT NOT NULL, turn_id UUID, turn_attempt INTEGER, started_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(),
18
+ finished_at TIMESTAMPTZ, input BIGINT, output BIGINT,
19
+ kind TEXT NOT NULL CHECK(kind IN ('chat','background')), task TEXT NOT NULL,
20
+ provider_model TEXT NOT NULL, app_id TEXT, conversation_id UUID, workflow_run_id UUID,
21
+ step_key TEXT, trace_id TEXT, workflow_id UUID, workflow_name TEXT, workflow_version INTEGER, pricing JSONB, cost NUMERIC(30,12), reserved NUMERIC(30,12) NOT NULL DEFAULT 0,
22
+ lease_expires_at TIMESTAMPTZ NOT NULL, status TEXT NOT NULL DEFAULT 'running', error_code TEXT,
23
+ CHECK(cost IS NULL OR cost>=0), CHECK(reserved>=0),
24
+ CHECK(input IS NULL OR input>=0), CHECK(output IS NULL OR output>=0)
25
+ )`;
26
+ await db`ALTER TABLE ai.inference_calls ADD COLUMN IF NOT EXISTS estimated BOOLEAN NOT NULL DEFAULT false`;
27
+ await db`CREATE INDEX IF NOT EXISTS inference_calls_workflow ON ai.inference_calls(workflow_run_id,started_at)`;
28
+ await db`CREATE INDEX IF NOT EXISTS inference_calls_started ON ai.inference_calls(started_at)`;
29
+ await db`CREATE INDEX IF NOT EXISTS inference_calls_turn ON ai.inference_calls(turn_id,started_at,id) WHERE kind='chat'`;
30
+ await db`CREATE INDEX IF NOT EXISTS inference_calls_user ON ai.inference_calls(user_id,started_at)`;
31
+ await db`CREATE INDEX IF NOT EXISTS inference_calls_service ON ai.inference_calls(service_account_id,started_at)`;
32
+ await db`CREATE TABLE IF NOT EXISTS ai.cost_resets (
33
+ request_id UUID PRIMARY KEY, user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE,
34
+ service_account_id UUID REFERENCES auth.service_accounts(id) ON DELETE CASCADE,
35
+ scope TEXT NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(),
36
+ actor_id UUID REFERENCES auth.users(id) ON DELETE SET NULL,
37
+ CHECK(num_nonnulls(user_id,service_account_id)=1)
38
+ )`;
39
+ await db`CREATE INDEX IF NOT EXISTS cost_resets_user ON ai.cost_resets(user_id,service_account_id,scope,created_at)`;
40
+ await db`CREATE TABLE IF NOT EXISTS ai.cost_changes (
41
+ revision INTEGER PRIMARY KEY, actor_id UUID REFERENCES auth.users(id) ON DELETE SET NULL,
42
+ created_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(), config JSONB NOT NULL
43
+ )`;
44
+ });
45
+ }
@@ -0,0 +1,168 @@
1
+ import { hasBillableAiPricing } from "../shared/ai-costs";
2
+ import { readAiSettingsState } from "./settings";
3
+ import { sql, type SQL } from "bun";
4
+ import { buildAccessPrincipalCondition, type AccessSubject } from "../server/services/access";
5
+ import { AiQuotaConfigSchema, type AiQuotaConfig, type AiQuotaSnapshot, type AiQuotaIdentity } from "../shared/ai-quotas";
6
+
7
+ export class AiQuotaError extends Error {
8
+ constructor(
9
+ public code: "quota_exhausted" | "quota_usage_unknown" | "quota_conflict",
10
+ message: string,
11
+ ) {
12
+ super(message);
13
+ }
14
+ }
15
+ const ids = (s: AccessSubject) => ({
16
+ user: s.type === "user" ? s.userId : null,
17
+ service: s.type === "service_account" ? s.serviceAccountId : null,
18
+ });
19
+ export const quotaWindow = (anchor: string, hours: number, now: Date) => {
20
+ const width = hours * 3_600_000,
21
+ start = Date.parse(anchor);
22
+ const from = start + Math.floor((now.getTime() - start) / width) * width;
23
+ return { from: new Date(from), until: new Date(from + width) };
24
+ };
25
+ export const aiQuotas = {
26
+ async config(db: SQL = sql): Promise<AiQuotaConfig> {
27
+ const [row] = await db<AiQuotaConfig[]>`SELECT enabled,revision,rules,unit,background FROM ai.cost_config WHERE singleton`;
28
+ return row ? { ...row, background: row.background ?? undefined } : { enabled: false, revision: 0, rules: [], unit: "EUR" };
29
+ },
30
+ async save(input: AiQuotaConfig, actorId: string) {
31
+ const config = AiQuotaConfigSchema.parse(input);
32
+ return sql.begin(async (db) => {
33
+ const [old] = await db<{ revision: number; unit: string }[]>`SELECT revision,unit FROM ai.cost_config WHERE singleton FOR UPDATE`;
34
+ if (!old || old.revision !== config.revision)
35
+ throw new AiQuotaError("quota_conflict", "Quota settings changed. Reload before saving.");
36
+ if ((config.unit ?? "EUR") !== old.unit) {
37
+ const [used] = await db`SELECT 1 FROM ai.inference_calls WHERE pricing IS NOT NULL LIMIT 1`;
38
+ if (used) throw new AiQuotaError("quota_conflict", "The accounting unit cannot change after priced usage has been recorded.");
39
+ }
40
+ const revision = config.revision + 1;
41
+ await db`UPDATE ai.cost_config SET enabled=${config.enabled},revision=${revision},rules=(${JSON.stringify(config.rules)}::text)::jsonb,unit=${config.unit ?? "EUR"},background=(${config.background ? JSON.stringify(config.background) : null}::text)::jsonb WHERE singleton`;
42
+ await db`INSERT INTO ai.cost_changes(revision,actor_id,config) VALUES(${revision},${actorId}::uuid,(${JSON.stringify(config)}::text)::jsonb)`;
43
+ return { ...config, revision };
44
+ });
45
+ },
46
+ async snapshot(subject: AccessSubject, model?: string, now = new Date(), db: SQL = sql): Promise<AiQuotaSnapshot> {
47
+ const config = await aiQuotas.config(db);
48
+ const identity = ids(subject);
49
+ const pricedModels = new Set((await readAiSettingsState()).profiles.filter((p) => hasBillableAiPricing(p.pricing)).map((p) => p.id));
50
+ const matches = buildAccessPrincipalCondition({
51
+ subject,
52
+ columns: {
53
+ userId: sql`NULLIF(g->'principal'->>'userId','')::uuid`,
54
+ groupId: sql`NULLIF(g->'principal'->>'groupId','')::uuid`,
55
+ serviceAccountId: sql`NULLIF(g->'principal'->>'serviceAccountId','')::uuid`,
56
+ authenticatedOnly: sql`(g->'principal'->>'type'='authenticated')`,
57
+ },
58
+ });
59
+ const balances: AiQuotaSnapshot["balances"] = [];
60
+ for (const rule of config.rules.filter((r) => !model || r.scope === "*" || r.scope === model)) {
61
+ const grants = await db<
62
+ { limit: number | null; principal: AiQuotaConfig["rules"][number]["grants"][number]["principal"]; label: string }[]
63
+ >`
64
+ SELECT (g->>'limit')::float8 AS limit, g->'principal' AS principal,
65
+ COALESCE(u.display_name,u.uid,gr.name,sa.name,'Authenticated users') AS label
66
+ FROM jsonb_array_elements((${JSON.stringify(rule.grants)}::text)::jsonb) g
67
+ LEFT JOIN auth.users u ON u.id=NULLIF(g->'principal'->>'userId','')::uuid
68
+ LEFT JOIN auth.groups gr ON gr.id=NULLIF(g->'principal'->>'groupId','')::uuid
69
+ LEFT JOIN auth.service_accounts sa ON sa.id=NULLIF(g->'principal'->>'serviceAccountId','')::uuid
70
+ WHERE ${matches}`;
71
+ const limit = grants.some((g) => g.limit === null) ? null : Math.max(0, ...grants.map((g) => g.limit ?? 0));
72
+ const window = quotaWindow(rule.anchor, rule.hours, now);
73
+ const [counts] = await db<{ input: number; output: number; used: number; unknown: number; estimated: number }[]>`
74
+ SELECT COALESCE(sum(c.cost),0)::float8 AS used, COALESCE(sum(c.input),0)::float8 AS input, COALESCE(sum(c.output),0)::float8 AS output, count(*) FILTER(WHERE c.estimated)::int AS estimated,
75
+ count(*) FILTER(WHERE (COALESCE((c.pricing->>'inputPerMillion')::numeric,0)>0 OR COALESCE((c.pricing->>'outputPerMillion')::numeric,0)>0) AND c.cost IS NULL AND
76
+ (c.finished_at IS NOT NULL OR (c.lease_expires_at <= now() OR (c.turn_id IS NOT NULL AND NOT EXISTS(SELECT 1 FROM ai.turns t WHERE t.id=c.turn_id AND t.attempt=c.turn_attempt AND t.status='running' AND t.lease_expires_at>now())))))::int AS unknown
77
+ FROM ai.inference_calls c
78
+ WHERE c.kind='chat' AND c.user_id IS NOT DISTINCT FROM ${identity.user}::uuid AND c.service_account_id IS NOT DISTINCT FROM ${identity.service}::uuid
79
+ AND (${rule.scope}='*' OR c.model_profile_id=${rule.scope})
80
+ AND c.started_at>=${window.from} AND c.started_at<${window.until}
81
+ AND c.started_at>COALESCE((SELECT max(r.created_at) FROM ai.cost_resets r
82
+ WHERE r.user_id IS NOT DISTINCT FROM ${identity.user}::uuid AND r.service_account_id IS NOT DISTINCT FROM ${identity.service}::uuid AND r.scope=${rule.scope}),'-infinity'::timestamptz)`;
83
+ balances.push({
84
+ scope: rule.scope,
85
+ limit,
86
+ input: counts?.input ?? 0,
87
+ output: counts?.output ?? 0,
88
+ used: counts?.used ?? 0,
89
+ unknown: counts?.unknown ?? 0,
90
+ estimated: counts?.estimated ?? 0,
91
+ resetsAt: window.until.toISOString(),
92
+ sources: grants.filter((g) => g.limit === limit).map((g) => g.label),
93
+ sourceDetails: grants.filter((g) => g.limit === limit).map((g) => ({ principal: g.principal, displayName: g.label })),
94
+ bypassed: false,
95
+ });
96
+ }
97
+ const unlimited = balances.some((b) => b.scope === "*" && b.limit === null);
98
+ for (const b of balances)
99
+ b.bypassed =
100
+ !config.enabled ||
101
+ (model !== undefined && !pricedModels.has(model)) ||
102
+ (b.scope !== "*" && !pricedModels.has(b.scope)) ||
103
+ (unlimited && b.scope !== "*");
104
+ const usage = model
105
+ ? []
106
+ : await db<
107
+ { model: string; input: number; output: number; unknown: number }[]
108
+ >`SELECT model_profile_id AS model,COALESCE(sum(input),0)::float8 AS input,COALESCE(sum(output),0)::float8 AS output,count(*) FILTER(WHERE finished_at IS NOT NULL AND input IS NULL)::int AS unknown FROM ai.inference_calls
109
+ WHERE kind='chat' AND user_id IS NOT DISTINCT FROM ${identity.user}::uuid AND service_account_id IS NOT DISTINCT FROM ${identity.service}::uuid
110
+ GROUP BY model_profile_id ORDER BY model_profile_id`;
111
+ return { enabled: config.enabled, unit: config.unit ?? "EUR", balances, usage };
112
+ },
113
+ async assertAllowed(subject: AccessSubject, model: string) {
114
+ // Fast default path: no additional identity/usage queries on unrestricted installations.
115
+ const pricing = (await readAiSettingsState()).profiles.find((profile) => profile.id === model)?.pricing;
116
+ if (!hasBillableAiPricing(pricing)) return false;
117
+ const config = await aiQuotas.config();
118
+ if (!config.enabled || !config.rules.some((r) => r.scope === "*" || r.scope === model)) return false;
119
+ const snapshot = await aiQuotas.snapshot(subject, model);
120
+ for (const b of snapshot.balances) {
121
+ if (b.bypassed || b.limit === null) continue;
122
+ if (b.unknown)
123
+ throw new AiQuotaError(
124
+ "quota_usage_unknown",
125
+ "Chat usage could not be measured. Contact an administrator or wait for the quota reset.",
126
+ );
127
+ if (b.used >= b.limit) throw new AiQuotaError("quota_exhausted", `Chat cost limit reached. Resets at ${b.resetsAt}.`);
128
+ }
129
+ return snapshot.balances.some((b) => !b.bypassed && b.limit !== null);
130
+ },
131
+ async prune() {
132
+ // Preserve every supported quota window, including rules added while limits were off.
133
+ // One bounded batch per runtime sweep avoids long deletes on busy installations.
134
+ await sql`DELETE FROM ai.inference_calls WHERE id IN (
135
+ SELECT c.id FROM ai.inference_calls c WHERE c.started_at < now() - interval '8760 hours'
136
+ AND (c.turn_id IS NOT NULL OR c.finished_at IS NOT NULL OR c.lease_expires_at<=now())
137
+ AND NOT EXISTS(SELECT 1 FROM ai.turns t WHERE t.id=c.turn_id AND t.attempt=c.turn_attempt
138
+ AND t.status='running' AND t.lease_expires_at>now())
139
+ ORDER BY c.started_at LIMIT 1000 FOR UPDATE SKIP LOCKED)`;
140
+ },
141
+ async reset(subject: AccessSubject, scope: string, requestId: string, actorId: string) {
142
+ const identity = ids(subject);
143
+ await sql.begin(async (db) => {
144
+ // The unique key makes transport retries safe; do not accept reuse for another target.
145
+ await db`INSERT INTO ai.cost_resets(request_id,user_id,service_account_id,scope,actor_id)
146
+ VALUES(${requestId}::uuid,${identity.user}::uuid,${identity.service}::uuid,${scope},${actorId}::uuid) ON CONFLICT DO NOTHING`;
147
+ const [row] = await db`SELECT 1 FROM ai.cost_resets WHERE request_id=${requestId}::uuid AND scope=${scope}
148
+ AND user_id IS NOT DISTINCT FROM ${identity.user}::uuid AND service_account_id IS NOT DISTINCT FROM ${identity.service}::uuid AND actor_id=${actorId}::uuid`;
149
+ if (!row) throw new AiQuotaError("quota_conflict", "Reset request was already used for another target.");
150
+ });
151
+ },
152
+ async users(search: string, page: number): Promise<{ items: AiQuotaIdentity[]; total: number; page: number; perPage: number }> {
153
+ const source = sql`SELECT 'user'::text AS type,u.id,COALESCE(NULLIF(u.display_name,''),u.uid) AS label,
154
+ (SELECT max(started_at) FROM ai.inference_calls WHERE kind='chat' AND user_id=u.id) AS last_used
155
+ FROM auth.users u WHERE (${search}<>'' OR EXISTS(SELECT 1 FROM ai.inference_calls WHERE kind='chat' AND user_id=u.id) OR EXISTS(SELECT 1 FROM ai.conversations c JOIN ai.turns t ON t.conversation_id=c.id WHERE c.created_by_user_id=u.id AND t.run_config->>'assistantChat'='true' AND t.run_config->'mandate' IS NULL))
156
+ UNION ALL SELECT 'service_account',s.id,s.name,(SELECT max(started_at) FROM ai.inference_calls WHERE kind='chat' AND service_account_id=s.id)
157
+ FROM auth.service_accounts s WHERE (${search}<>'' OR EXISTS(SELECT 1 FROM ai.inference_calls WHERE kind='chat' AND service_account_id=s.id))`;
158
+ const filtered = sql`SELECT * FROM (${source}) users WHERE position(lower(${search}) in lower(label))>0 OR id::text=${search}`;
159
+ const [count] = await sql<{ total: number }[]>`SELECT count(*)::int AS total FROM (${filtered}) q`;
160
+ const total = count?.total ?? 0,
161
+ perPage = 25,
162
+ current = Math.min(page, Math.max(1, Math.ceil(total / perPage)));
163
+ const items = await sql<
164
+ AiQuotaIdentity[]
165
+ >`SELECT type,id,label,to_char(last_used AT TIME ZONE 'UTC','YYYY-MM-DD"T"HH24:MI:SS.MS"Z"') AS "lastUsed" FROM (${filtered}) q ORDER BY last_used DESC NULLS LAST,label,id LIMIT ${perPage} OFFSET ${(current - 1) * perPage}`;
166
+ return { items, total, page: current, perPage };
167
+ },
168
+ };