@k2b/cloud 0.43.0 → 0.44.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (267) hide show
  1. package/package.json +7 -2
  2. package/src/_internal/app-access-request.ts +13 -0
  3. package/src/_internal/app-access-resources.ts +141 -0
  4. package/src/_internal/app-event-catalog.ts +40 -0
  5. package/src/_internal/app-event-schemas.ts +127 -0
  6. package/src/_internal/application-package.ts +40 -0
  7. package/src/_internal/built-in-app-ids.json +27 -0
  8. package/src/_internal/capabilities.ts +158 -12
  9. package/src/_internal/capability-origin.ts +4 -0
  10. package/src/_internal/capability-streams.ts +7 -5
  11. package/src/_internal/define-app.ts +57 -6
  12. package/src/_internal/object-store-area.ts +9 -0
  13. package/src/_internal/object-store-config.ts +101 -0
  14. package/src/_internal/object-store-key.ts +14 -0
  15. package/src/_internal/process-identity.ts +7 -2
  16. package/src/_internal/registry-validation.ts +11 -1
  17. package/src/_internal/registry.ts +8 -1
  18. package/src/_internal/runtime-context.ts +8 -1
  19. package/src/access/AppAccessEditor.tsx +2 -0
  20. package/src/access/PermissionEditor.tsx +9 -3
  21. package/src/access/PrincipalPicker.tsx +14 -4
  22. package/src/access/ResourceApiKeys.tsx +3 -4
  23. package/src/access/app-access-messages.ts +6 -0
  24. package/src/account/ProfilePictureDialog.tsx +699 -0
  25. package/src/account/profile-picture-messages.ts +269 -0
  26. package/src/account/ui.ts +7 -0
  27. package/src/ai/admin.ts +3 -0
  28. package/src/ai/app-frame.ts +12 -0
  29. package/src/ai/application-tasks.ts +101 -0
  30. package/src/ai/assistant-models.ts +126 -64
  31. package/src/ai/capabilities.ts +19 -4
  32. package/src/ai/chart-block.ts +63 -30
  33. package/src/ai/chat/chart-block.tsx +12 -3
  34. package/src/ai/chat/composer-adapter.ts +8 -7
  35. package/src/ai/chat/message-actions.tsx +1 -1
  36. package/src/ai/chat/message-utils.ts +2 -2
  37. package/src/ai/chat/presentation.tsx +22 -1
  38. package/src/ai/chat-quotas.ts +5 -4
  39. package/src/ai/chat-tasks.ts +2 -2
  40. package/src/ai/client/controller.ts +11 -10
  41. package/src/ai/client/projection.ts +3 -3
  42. package/src/ai/compaction.ts +3 -3
  43. package/src/ai/credentials.ts +13 -114
  44. package/src/ai/default-tool-contracts.ts +4 -3
  45. package/src/ai/dictation-runtime.ts +4 -4
  46. package/src/ai/enrich.ts +2 -2
  47. package/src/ai/executor.ts +31 -22
  48. package/src/ai/http.ts +17 -29
  49. package/src/ai/index.ts +11 -5
  50. package/src/ai/inference-calls.ts +5 -4
  51. package/src/ai/maintenance.ts +1 -1
  52. package/src/ai/memory-learning-runs.ts +5 -5
  53. package/src/ai/memory-learning.ts +3 -3
  54. package/src/ai/message-queue.ts +1 -2
  55. package/src/ai/migrate.ts +75 -42
  56. package/src/ai/model-access-migrate.ts +3 -17
  57. package/src/ai/model-access.ts +44 -85
  58. package/src/ai/model-card.tsx +134 -0
  59. package/src/ai/model-catalog.ts +359 -0
  60. package/src/ai/model-chooser-messages.ts +88 -0
  61. package/src/ai/model-chooser.tsx +577 -0
  62. package/src/ai/model-delete.ts +234 -0
  63. package/src/ai/model-fixture.ts +39 -0
  64. package/src/ai/model-icon.tsx +36 -0
  65. package/src/ai/model-overview.ts +53 -0
  66. package/src/ai/model-usage.ts +45 -0
  67. package/src/ai/openai-api-messages.ts +0 -4
  68. package/src/ai/openai-api.ts +26 -9
  69. package/src/ai/personal-agent.ts +1 -1
  70. package/src/ai/prefs.ts +12 -10
  71. package/src/ai/projects-routes.ts +2 -1
  72. package/src/ai/projects.ts +13 -12
  73. package/src/ai/protocol.ts +2 -2
  74. package/src/ai/provider-connections.ts +161 -0
  75. package/src/ai/provider-errors.ts +55 -6
  76. package/src/ai/provider-fetch.ts +4 -0
  77. package/src/ai/provider.ts +1 -1
  78. package/src/ai/quota-limits.ts +36 -0
  79. package/src/ai/quota-provider.ts +4 -2
  80. package/src/ai/quota-report.ts +7 -4
  81. package/src/ai/quotas.ts +19 -8
  82. package/src/ai/recent-resources.ts +6 -0
  83. package/src/ai/request-headers.ts +14 -87
  84. package/src/ai/resource-refs.ts +6 -2
  85. package/src/ai/routes.ts +17 -18
  86. package/src/ai/runtime.ts +50 -22
  87. package/src/ai/settings.ts +93 -580
  88. package/src/ai/store.ts +45 -23
  89. package/src/ai/stream.ts +1 -1
  90. package/src/ai/structured-runs.ts +2 -2
  91. package/src/ai/structured.ts +21 -44
  92. package/src/ai/task-contracts.ts +25 -5
  93. package/src/ai/task-execution.ts +15 -6
  94. package/src/ai/task-model.ts +9 -0
  95. package/src/ai/transcription.ts +27 -22
  96. package/src/ai/types.ts +50 -62
  97. package/src/ai/ui.tsx +13 -0
  98. package/src/ai/usage.ts +8 -8
  99. package/src/ai/validate.ts +8 -7
  100. package/src/ai/view-block.ts +2 -2
  101. package/src/api/accounts-entities.ts +16 -0
  102. package/src/api/admin-ai-quotas.ts +34 -56
  103. package/src/api/admin-ai-usage.ts +1 -1
  104. package/src/api/admin-ai.ts +173 -0
  105. package/src/api/admin-core-settings.ts +68 -244
  106. package/src/api/app-approval.ts +3 -1
  107. package/src/api/app-event-delivery.ts +51 -0
  108. package/src/api/app-events.ts +123 -0
  109. package/src/api/app-resource-access.ts +48 -0
  110. package/src/api/auth/account-mismatch.ts +24 -0
  111. package/src/api/auth.ts +26 -3
  112. package/src/api/capabilities.ts +28 -4
  113. package/src/api/index.ts +7 -1
  114. package/src/api/mcp.ts +175 -2
  115. package/src/api/me.ts +6 -4
  116. package/src/api/pwa-phone.ts +75 -26
  117. package/src/api/pwa.ts +34 -8
  118. package/src/api/resource-links.ts +83 -6
  119. package/src/api/resource-resolve.ts +50 -0
  120. package/src/api/search/schemas.ts +3 -1
  121. package/src/api/search.ts +9 -7
  122. package/src/app-pwa/contracts.ts +4 -0
  123. package/src/app-pwa/core-query-error.ts +20 -0
  124. package/src/app-pwa/index.ts +1 -0
  125. package/src/app-pwa/service.ts +91 -0
  126. package/src/app-pwa/transport.ts +22 -0
  127. package/src/approvals/messages.ts +2 -2
  128. package/src/browser/cloud-id.ts +86 -0
  129. package/src/browser/resource-clipboard.ts +4 -0
  130. package/src/browser/resource-links.ts +52 -0
  131. package/src/capabilities/server-invoke.ts +2 -11
  132. package/src/capabilities/testing.ts +5 -2
  133. package/src/cli/access.ts +6 -5
  134. package/src/cli/admin/ai-models.ts +199 -0
  135. package/src/cli/admin/ai-quotas.ts +6 -102
  136. package/src/cli/admin/ai-usage.ts +4 -4
  137. package/src/cli/admin/app-credentials.ts +5 -3
  138. package/src/cli/admin/index.ts +5 -2
  139. package/src/cli/apps.ts +89 -26
  140. package/src/cli/capabilities.ts +22 -1
  141. package/src/cli/events.ts +320 -0
  142. package/src/config/env.ts +40 -0
  143. package/src/contracts/app-access.ts +26 -2
  144. package/src/contracts/app-details.ts +89 -0
  145. package/src/contracts/app-events.ts +51 -0
  146. package/src/contracts/app.ts +21 -5
  147. package/src/contracts/capabilities.ts +143 -3
  148. package/src/contracts/index.ts +2 -0
  149. package/src/contracts/notification-types.ts +36 -9
  150. package/src/contracts/pwa.ts +17 -1
  151. package/src/contracts/registry.ts +6 -0
  152. package/src/contracts/resource-clipboard.ts +35 -0
  153. package/src/contracts/resource-pages.ts +140 -0
  154. package/src/contracts/resource-resolution.ts +13 -0
  155. package/src/contracts/shared.ts +4 -0
  156. package/src/contracts/user-notifications.ts +2 -0
  157. package/src/events/app-event-delivery.ts +100 -0
  158. package/src/events/app-events.testing.ts +65 -0
  159. package/src/events/app-events.ts +134 -0
  160. package/src/events/core-user-created.ts +24 -0
  161. package/src/events/index.ts +9 -0
  162. package/src/events/testing.ts +2 -0
  163. package/src/index.ts +1 -1
  164. package/src/server/api/respond.ts +8 -3
  165. package/src/server/index.ts +1 -0
  166. package/src/server/middleware/app-access.ts +1 -1
  167. package/src/server/middleware/auth.ts +21 -7
  168. package/src/server/middleware/index.ts +1 -0
  169. package/src/server/middleware/invocation.ts +2 -0
  170. package/src/server/public-storage.ts +106 -0
  171. package/src/server/services/access.ts +8 -0
  172. package/src/services/accounts/app.ts +6 -0
  173. package/src/services/accounts/entities.ts +14 -3
  174. package/src/services/accounts/groups.ts +3 -1
  175. package/src/services/accounts/identities.ts +62 -10
  176. package/src/services/accounts/users.ts +7 -6
  177. package/src/services/app-access.ts +25 -7
  178. package/src/services/app-details.ts +224 -0
  179. package/src/services/app-discovery.ts +2 -1
  180. package/src/services/app-event-shapes.ts +139 -0
  181. package/src/services/app-events/accept.ts +116 -0
  182. package/src/services/app-events/admin.ts +250 -0
  183. package/src/services/app-events/declarations.ts +187 -0
  184. package/src/services/app-events/deliver.ts +218 -0
  185. package/src/services/app-events.ts +92 -0
  186. package/src/services/config-conflict.ts +11 -0
  187. package/src/services/core-user-events.ts +1 -0
  188. package/src/services/freeipa-config.ts +9 -0
  189. package/src/services/geo/ledger.ts +28 -10
  190. package/src/services/geo/service.ts +43 -24
  191. package/src/services/identity/core-origin.ts +12 -0
  192. package/src/services/identity/invocation-actor.ts +20 -2
  193. package/src/services/identity/invocation-authority.ts +4 -0
  194. package/src/services/identity/invocation-token.ts +238 -56
  195. package/src/services/identity/issue-invocation.ts +6 -1
  196. package/src/services/image-metadata.ts +65 -1
  197. package/src/services/index.ts +13 -1
  198. package/src/services/ipa/sync.ts +6 -2
  199. package/src/services/ipa/users.ts +4 -1
  200. package/src/services/mandates/policy.ts +19 -12
  201. package/src/services/notifications/browser.ts +4 -0
  202. package/src/services/notifications/catalog.ts +5 -2
  203. package/src/services/notifications/platform.ts +7 -2
  204. package/src/services/notifications/user.ts +18 -7
  205. package/src/services/oauth-tokens.ts +28 -2
  206. package/src/services/object-store.ts +217 -0
  207. package/src/services/providers/local/users.ts +5 -0
  208. package/src/services/pwa-devices.ts +147 -37
  209. package/src/services/session/index.ts +14 -0
  210. package/src/services/settings/core-settings.ts +71 -43
  211. package/src/services/settings/index.ts +8 -0
  212. package/src/services/settings/versions.ts +26 -0
  213. package/src/shared/ai-models.ts +274 -0
  214. package/src/shared/ai-quotas.ts +2 -2
  215. package/src/shared/ai-usage.ts +2 -2
  216. package/src/shared/index.ts +2 -0
  217. package/src/shared-text/documents.ts +25 -8
  218. package/src/shared-text/log.ts +1 -1
  219. package/src/shared-text/text-diff.ts +75 -0
  220. package/src/ssr/AdminArea.tsx +131 -0
  221. package/src/ssr/AdminLayout.tsx +9 -3
  222. package/src/ssr/AdminSidebar.tsx +7 -7
  223. package/src/ssr/AppAccessDenied.tsx +2 -0
  224. package/src/ssr/AppBadge.tsx +14 -0
  225. package/src/ssr/AppLaunchpadPanel.tsx +1 -1
  226. package/src/ssr/CloudShell.island.tsx +25 -4
  227. package/src/ssr/Layout.tsx +8 -6
  228. package/src/ssr/PwaLayout.tsx +16 -1
  229. package/src/ssr/PwaPartCount.island.tsx +23 -0
  230. package/src/ssr/SettingsSaveOutcome.tsx +58 -0
  231. package/src/ssr/SettingsSaveStatus.island.tsx +16 -0
  232. package/src/ssr/__snapshots__/Layout.unchanged.render.test.tsx.snap +3 -3
  233. package/src/ssr/admin-active-link.ts +16 -0
  234. package/src/ssr/admin-navigation.ts +25 -20
  235. package/src/ssr/app-badges.ts +31 -7
  236. package/src/ssr/index.ts +8 -0
  237. package/src/ssr/page-trail-runtime.ts +3 -1
  238. package/src/ssr/platform-messages.ts +9 -1
  239. package/src/ssr/pwa-messages.ts +2 -2
  240. package/src/ssr/rail-navigation.ts +5 -1
  241. package/src/ssr/settings-draft.ts +153 -0
  242. package/src/ssr/shell-data.tsx +2 -0
  243. package/src/styles/global.css +1 -0
  244. package/src/styles/profile-picture.css +270 -0
  245. package/src/styles/utilities-navigation.css +6 -0
  246. package/src/workflows/ai/policy.ts +49 -0
  247. package/src/workflows/ai/runtime.ts +131 -21
  248. package/src/workflows/ai/store.ts +283 -35
  249. package/src/workflows/ai/types.ts +8 -2
  250. package/src/workflows/ai-actions.ts +148 -120
  251. package/src/workflows/authoring.ts +31 -0
  252. package/src/workflows/contracts.ts +1 -0
  253. package/src/workflows/definition.ts +2 -0
  254. package/src/workflows/editor.ts +15 -0
  255. package/src/workflows/index.ts +2 -1
  256. package/src/workflows/language/compiler.ts +34 -3
  257. package/src/workflows/runtime/executor.ts +132 -33
  258. package/src/workflows/runtime/index.ts +2 -0
  259. package/src/workflows/runtime/ports.ts +5 -1
  260. package/src/workflows/store/actions.ts +1 -1
  261. package/src/workflows/store/definitions.ts +5 -0
  262. package/src/workflows/store/runs.ts +5 -3
  263. package/src/workflows/store/worker.ts +7 -2
  264. package/src/ai/model-pricing.ts +0 -43
  265. package/src/ai/model-request-settings.ts +0 -98
  266. package/src/ai/settings-messages.ts +0 -129
  267. package/src/shared/ai-model-request-settings.ts +0 -21
@@ -12,6 +12,7 @@ import {
12
12
  } from "../server/services/access";
13
13
  import { stripImageMetadata } from "../services/image-metadata";
14
14
  import { toPgUuidArray } from "../services/postgres";
15
+ import { type AiModelRequest, AiModelRequestSchema } from "../shared/ai-models";
15
16
  import { AiFileVersionConflict, AiFileWriteError, aiFileContentVersion } from "./file-content-version";
16
17
  import { isAiImage } from "./file-media-type";
17
18
  import { mountAiProjectFilePath } from "./file-mount";
@@ -42,7 +43,7 @@ export type AiProject = {
42
43
  description: string;
43
44
  icon: string;
44
45
  instructions: string;
45
- defaultModelProfileId: string | null;
46
+ defaultModel: AiModelRequest | null;
46
47
  permission: AiProjectPermission;
47
48
  revision: number;
48
49
  createdAt: string;
@@ -115,7 +116,7 @@ type ProjectRow = {
115
116
  description: string;
116
117
  icon: string;
117
118
  instructions: string;
118
- default_model_profile_id: string | null;
119
+ default_model: AiModelRequest | null;
119
120
  revision: number;
120
121
  created_at: Date | string;
121
122
  updated_at: Date | string;
@@ -213,7 +214,7 @@ const toProject = async (row: ProjectRow, subject: AccessSubject | null, db: SQL
213
214
  description: row.description,
214
215
  icon: row.icon,
215
216
  instructions: row.instructions,
216
- defaultModelProfileId: row.default_model_profile_id,
217
+ defaultModel: row.default_model ? AiModelRequestSchema.parse(row.default_model) : null,
217
218
  permission,
218
219
  revision: Number(row.revision),
219
220
  createdAt: iso(row.created_at),
@@ -417,17 +418,17 @@ export const aiProjects = {
417
418
  description?: string;
418
419
  icon?: string;
419
420
  instructions?: string;
420
- defaultModelProfileId?: string | null;
421
+ defaultModel?: AiModelRequest | null;
421
422
  }): Promise<AiProject> {
422
423
  return sql.begin(async (tx) => {
423
424
  const rows = await withAiShortIdForDb(
424
425
  tx,
425
426
  "idx_ai_projects_short_id",
426
427
  (attempt, shortId) => attempt<ProjectRow[]>`
427
- INSERT INTO ai.projects (short_id, name, description, icon, instructions, default_model_profile_id)
428
+ INSERT INTO ai.projects (short_id, name, description, icon, instructions, default_model)
428
429
  VALUES (
429
430
  ${shortId}, ${input.name.trim()}, ${input.description?.trim() ?? ""}, ${input.icon?.trim() || "ti ti-folders"},
430
- ${input.instructions?.trim() ?? ""}, ${input.defaultModelProfileId?.trim() || null}
431
+ ${input.instructions?.trim() ?? ""}, ${input.defaultModel ? JSON.stringify(AiModelRequestSchema.parse(input.defaultModel)) : null}::text::jsonb
431
432
  )
432
433
  RETURNING *
433
434
  `,
@@ -488,9 +489,9 @@ export const aiProjects = {
488
489
  async update(
489
490
  projectId: string,
490
491
  subject: AccessSubject | null,
491
- input: Partial<Pick<AiProject, "name" | "description" | "icon" | "instructions" | "defaultModelProfileId">>,
492
+ input: Partial<Pick<AiProject, "name" | "description" | "icon" | "instructions" | "defaultModel">>,
492
493
  ): Promise<AiProject | null> {
493
- const changesTrustedInstructions = Object.hasOwn(input, "instructions") || Object.hasOwn(input, "defaultModelProfileId");
494
+ const changesTrustedInstructions = Object.hasOwn(input, "instructions") || Object.hasOwn(input, "defaultModel");
494
495
  if (!(await requireProject(projectId, subject, changesTrustedInstructions ? "admin" : "write"))) return null;
495
496
  const rows = await sql<ProjectRow[]>`
496
497
  UPDATE ai.projects
@@ -498,9 +499,9 @@ export const aiProjects = {
498
499
  description = COALESCE(${input.description?.trim() ?? null}, description),
499
500
  icon = COALESCE(${input.icon?.trim() ?? null}, icon),
500
501
  instructions = COALESCE(${input.instructions?.trim() ?? null}, instructions),
501
- default_model_profile_id = CASE
502
- WHEN ${Object.hasOwn(input, "defaultModelProfileId")} THEN ${input.defaultModelProfileId?.trim() || null}
503
- ELSE default_model_profile_id
502
+ default_model = CASE
503
+ WHEN ${Object.hasOwn(input, "defaultModel")} THEN ${input.defaultModel ? JSON.stringify(AiModelRequestSchema.parse(input.defaultModel)) : null}::text::jsonb
504
+ ELSE default_model
504
505
  END,
505
506
  revision = revision + 1,
506
507
  updated_at = now()
@@ -563,7 +564,7 @@ export const aiProjects = {
563
564
  instructions: project.instructions,
564
565
  context: lines.join("\n").slice(0, AI_PROJECT_PROMPT_MANIFEST_MAX_CHARS),
565
566
  references: references.map((reference) => reference.ref),
566
- defaultModelProfileId: project.defaultModelProfileId,
567
+ defaultModel: project.defaultModel,
567
568
  };
568
569
  });
569
570
  },
@@ -90,7 +90,7 @@ export type AiTurnFinishedStatus = "completed" | "failed" | "aborted";
90
90
  export type AiWireEvent =
91
91
  | (AiWireEventBase & {
92
92
  type: "turn_started";
93
- modelProfileId: string;
93
+ modelId: string;
94
94
  providerModel: string;
95
95
  /** Authoritative ordered baseline for this attempt. Optional for older senders. */
96
96
  blocks?: AiTurnBlock[];
@@ -116,7 +116,7 @@ export type AiTurnSnapshot = {
116
116
  /** Last wire seq reflected in `blocks` (attempt-scoped). */
117
117
  seq: number;
118
118
  blocks: AiTurnBlock[];
119
- modelProfileId: string | null;
119
+ modelId: string | null;
120
120
  createdAt: string;
121
121
  /** Time the turn already waited for answered user actions such as approvals. Absent from older servers. */
122
122
  actionWaitMs?: number;
@@ -0,0 +1,161 @@
1
+ import { sql } from "bun";
2
+ import { z } from "zod";
3
+ import type { AiProvider } from "../shared/ai-models";
4
+ import { getAiCredential } from "./credentials";
5
+ import { getAiRequestHeaders } from "./request-headers";
6
+
7
+ export type AiProviderTestResult = {
8
+ status: "ok" | "unreachable" | "key_rejected" | "error";
9
+ httpStatus?: number;
10
+ message: string;
11
+ latencyMs?: number;
12
+ };
13
+ export type AiAvailableProviderModel = { providerModel: string; label?: string; kind?: "chat" | "transcription"; contextWindow?: number };
14
+ type Connection = Pick<AiProvider, "type" | "baseUrl"> & { apiKey?: string; headers?: Record<string, string> };
15
+ const defaults = {
16
+ openai: "https://api.openai.com/v1",
17
+ openrouter: "https://openrouter.ai/api/v1",
18
+ anthropic: "https://api.anthropic.com/v1",
19
+ mistral: "https://api.mistral.ai/v1",
20
+ gemini: "https://generativelanguage.googleapis.com/v1beta/openai",
21
+ ollama: "http://localhost:11434",
22
+ vllm: "http://localhost:8000/v1",
23
+ "openai-compatible": "",
24
+ };
25
+ export class AiProviderListError extends Error {
26
+ constructor(
27
+ readonly code: "unreachable" | "key_rejected" | "list_failed",
28
+ message: string,
29
+ readonly httpStatus?: number,
30
+ ) {
31
+ super(message);
32
+ }
33
+ }
34
+ export const classifyProviderFailure = (error: unknown): AiProviderTestResult => {
35
+ const details = z
36
+ .object({ status: z.number().optional(), statusCode: z.number().optional(), code: z.string().optional(), name: z.string().optional() })
37
+ .passthrough()
38
+ .safeParse(error);
39
+ const httpStatus =
40
+ error instanceof AiProviderListError
41
+ ? error.httpStatus
42
+ : details.success
43
+ ? (details.data.status ?? details.data.statusCode)
44
+ : undefined;
45
+ const status =
46
+ httpStatus === 401 || httpStatus === 403
47
+ ? "key_rejected"
48
+ : (error instanceof AiProviderListError && error.code === "unreachable") ||
49
+ error instanceof TypeError ||
50
+ (details.success && ["TimeoutError", "AbortError"].includes(details.data.name ?? "")) ||
51
+ (details.success && ["ECONNREFUSED", "ETIMEDOUT", "ENOTFOUND", "ECONNRESET"].includes(details.data.code ?? ""))
52
+ ? "unreachable"
53
+ : "error";
54
+ return {
55
+ status,
56
+ ...(httpStatus ? { httpStatus } : {}),
57
+ message:
58
+ status === "key_rejected"
59
+ ? "The provider rejected the credential."
60
+ : status === "unreachable"
61
+ ? "The provider did not respond within 10 seconds or could not be reached."
62
+ : "The provider request failed.",
63
+ };
64
+ };
65
+ /** Best-effort persistence never rejects: callers await it inside their provider error boundary. */
66
+ export const recordAiProviderStatus = async (id: string, result: AiProviderTestResult, force = false) => {
67
+ try {
68
+ await sql`UPDATE ai.providers SET status=${result.status},status_code=${result.httpStatus?.toString() ?? null},status_message=${result.message},status_checked_at=clock_timestamp() WHERE id=${id} AND (${force} OR status IS DISTINCT FROM ${result.status} OR status_code IS DISTINCT FROM ${result.httpStatus?.toString() ?? null})`;
69
+ } catch {
70
+ // A failed database write must not start another write through the persistent logger.
71
+ console.warn("[ai:providers] Could not persist provider connection status", { providerId: id });
72
+ }
73
+ };
74
+ const requestModels = async (provider: Connection, signal?: AbortSignal, after?: string) => {
75
+ const base = (provider.baseUrl ?? defaults[provider.type]).replace(/\/$/, "");
76
+ if (!base) throw new AiProviderListError("list_failed", "The provider has no base URL.");
77
+ const headers = new Headers(provider.headers);
78
+ if (provider.type === "anthropic") {
79
+ headers.set("anthropic-version", "2023-06-01");
80
+ if (provider.apiKey) headers.set("x-api-key", provider.apiKey);
81
+ } else if (provider.apiKey) headers.set("authorization", `Bearer ${provider.apiKey}`);
82
+ let response: Response;
83
+ try {
84
+ response = await fetch(
85
+ `${base}${provider.type === "ollama" ? "/api/tags" : `/models${after ? `?after_id=${encodeURIComponent(after)}` : ""}`}`,
86
+ { headers, signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(10_000)]) : AbortSignal.timeout(10_000), redirect: "error" },
87
+ );
88
+ } catch {
89
+ throw new AiProviderListError("unreachable", "The provider could not be reached.");
90
+ }
91
+ if (!response.ok) {
92
+ await response.body?.cancel();
93
+ throw new AiProviderListError(
94
+ response.status === 401 || response.status === 403 ? "key_rejected" : "list_failed",
95
+ "The provider could not list models.",
96
+ response.status,
97
+ );
98
+ }
99
+ return response;
100
+ };
101
+ /** Cloud's one listing seam; move it to nessi listModels() when that API ships. */
102
+ export const listProviderModels = async (provider: Connection, signal?: AbortSignal): Promise<AiAvailableProviderModel[]> => {
103
+ const deadline = signal ? AbortSignal.any([signal, AbortSignal.timeout(10_000)]) : AbortSignal.timeout(10_000);
104
+ const models: AiAvailableProviderModel[] = [];
105
+ const seen = new Set<string>();
106
+ let after: string | undefined;
107
+ do {
108
+ const response = await requestModels(provider, deadline, after);
109
+ try {
110
+ const body: unknown = await response.json();
111
+ if (provider.type === "ollama")
112
+ return z
113
+ .object({ models: z.array(z.object({ name: z.string() })) })
114
+ .parse(body)
115
+ .models.map((model) => ({ providerModel: model.name }));
116
+ const page = z
117
+ .object({
118
+ data: z.array(
119
+ z.object({
120
+ id: z.string(),
121
+ name: z.string().optional(),
122
+ display_name: z.string().optional(),
123
+ context_length: z.number().int().positive().optional(),
124
+ }),
125
+ ),
126
+ has_more: z.boolean().optional(),
127
+ last_id: z.string().optional(),
128
+ })
129
+ .parse(body);
130
+ models.push(
131
+ ...page.data.map((model) => ({
132
+ providerModel: model.id,
133
+ ...((model.display_name ?? model.name) ? { label: model.display_name ?? model.name } : {}),
134
+ ...(model.context_length ? { contextWindow: model.context_length } : {}),
135
+ })),
136
+ );
137
+ if (provider.type !== "anthropic" || !page.has_more) return models;
138
+ after = page.last_id ?? page.data.at(-1)?.id;
139
+ if (!after || seen.has(after)) throw new Error("Non-advancing model cursor");
140
+ seen.add(after);
141
+ } catch {
142
+ throw new AiProviderListError("list_failed", "The provider returned an invalid model list.");
143
+ }
144
+ } while (!deadline.aborted);
145
+ throw new AiProviderListError("unreachable", "The provider model list timed out.");
146
+ };
147
+ export const storedProviderConnection = async (provider: AiProvider): Promise<Connection> => ({
148
+ ...provider,
149
+ apiKey: (await getAiCredential(provider.id)) ?? undefined,
150
+ headers: await getAiRequestHeaders(provider.id),
151
+ });
152
+ export const testAiProvider = async (provider: Connection, signal?: AbortSignal): Promise<AiProviderTestResult> => {
153
+ const start = Date.now();
154
+ try {
155
+ const response = await requestModels(provider, signal);
156
+ await response.body?.cancel();
157
+ return { status: "ok", httpStatus: response.status, message: "The provider connection works.", latencyMs: Date.now() - start };
158
+ } catch (error) {
159
+ return { ...classifyProviderFailure(error), latencyMs: Date.now() - start };
160
+ }
161
+ };
@@ -1,4 +1,6 @@
1
- import type { NessiIssue, Provider } from "@k2b/nessi/ai";
1
+ import type { NessiIssue, Provider, StreamEvent } from "@k2b/nessi/ai";
2
+ import { classifyProviderFailure, recordAiProviderStatus } from "./provider-connections";
3
+ import { type ProviderFetchMarks, runWithProviderFetchMarks } from "./provider-fetch";
2
4
 
3
5
  /** No provider-owned message, name, stack, cause, or extension fields cross this boundary. */
4
6
  export class AiProviderError extends Error {
@@ -63,27 +65,74 @@ const toolStreamReason = (reason: string) => {
63
65
  };
64
66
 
65
67
  /** Apply before accounting, retry, nessi loops, traces, capabilities, and loggers observe an adapter. */
66
- export const withProviderErrors = (provider: Provider): Provider => ({
68
+ export const withProviderErrors = (provider: Provider, providerId?: string): Provider => ({
67
69
  name: provider.name,
68
70
  family: provider.family,
69
71
  model: provider.model,
70
72
  contextWindow: provider.contextWindow,
71
73
  capabilities: provider.capabilities,
72
74
  complete: async (request) => {
75
+ const marks: ProviderFetchMarks = {};
73
76
  try {
74
- return await provider.complete(request);
75
- } catch {
77
+ const result = await runWithProviderFetchMarks(marks, () => provider.complete(request));
78
+ if (providerId && !request.signal?.aborted)
79
+ await recordAiProviderStatus(
80
+ providerId,
81
+ ["error", "aborted", "interrupted"].includes(result.finishReason ?? "")
82
+ ? classifyProviderFailure(marks.httpStatus ? { status: marks.httpStatus } : {})
83
+ : { status: "ok", message: "The provider connection works." },
84
+ );
85
+ return result;
86
+ } catch (error) {
87
+ if (providerId && !request.signal?.aborted)
88
+ await recordAiProviderStatus(
89
+ providerId,
90
+ classifyProviderFailure(marks.httpStatus ? { status: marks.httpStatus } : marks.networkFailure ? new TypeError() : error),
91
+ );
76
92
  throw new AiProviderError(request.signal?.aborted);
77
93
  }
78
94
  },
79
95
  stream: async function* (request) {
96
+ const marks: ProviderFetchMarks = {};
97
+ let iterator: AsyncIterator<StreamEvent> | undefined;
80
98
  try {
81
- for await (const event of provider.stream(request)) {
99
+ const activeIterator = provider.stream(request)[Symbol.asyncIterator]();
100
+ iterator = activeIterator;
101
+ let failed = false;
102
+ for (;;) {
103
+ const next = await runWithProviderFetchMarks(marks, () => activeIterator.next());
104
+ if (next.done) break;
105
+ const event = next.value;
106
+ if (event.type === "issue" && (event.issue.kind === "provider_error" || event.issue.kind === "timeout")) {
107
+ failed = true;
108
+ if (providerId)
109
+ await recordAiProviderStatus(
110
+ providerId,
111
+ event.issue.kind === "timeout"
112
+ ? { status: "unreachable", message: "The provider timed out." }
113
+ : classifyProviderFailure(
114
+ marks.httpStatus ? { status: marks.httpStatus } : marks.networkFailure ? new TypeError() : event.issue,
115
+ ),
116
+ );
117
+ }
82
118
  yield event.type === "issue" ? { type: "issue", issue: providerIssue(event.issue, request.tools) } : event;
83
119
  }
84
- } catch {
120
+ if (providerId && !failed && !request.signal?.aborted)
121
+ await recordAiProviderStatus(providerId, { status: "ok", message: "The provider connection works." });
122
+ } catch (error) {
123
+ if (providerId && !request.signal?.aborted)
124
+ await recordAiProviderStatus(
125
+ providerId,
126
+ classifyProviderFailure(marks.httpStatus ? { status: marks.httpStatus } : marks.networkFailure ? new TypeError() : error),
127
+ );
85
128
  // Includes synchronous creation, iteration, and iterator cleanup failures.
86
129
  throw new AiProviderError(request.signal?.aborted);
130
+ } finally {
131
+ try {
132
+ await iterator?.return?.();
133
+ } catch {
134
+ throw new AiProviderError(request.signal?.aborted);
135
+ }
87
136
  }
88
137
  },
89
138
  });
@@ -13,6 +13,8 @@ import { AsyncLocalStorage } from "node:async_hooks";
13
13
  */
14
14
  export type ProviderFetchMarks = {
15
15
  headersAt?: number;
16
+ httpStatus?: number;
17
+ networkFailure?: boolean;
16
18
  firstByteAt?: number;
17
19
  /** The request never reached the provider, or the provider answered with a status that says it did not process it. */
18
20
  refused?: boolean;
@@ -74,6 +76,7 @@ const install = (): void => {
74
76
  try {
75
77
  response = await realFetch(input, init);
76
78
  } catch (error) {
79
+ for (const store of stores) store.networkFailure = true;
77
80
  // A reset, an abort, or a timeout can follow delivery, so only an unsent request counts as refused.
78
81
  if (unsent(error))
79
82
  for (const store of stores) {
@@ -85,6 +88,7 @@ const install = (): void => {
85
88
  const wait = response.ok ? undefined : retryAfterMs(response.headers, headersAt);
86
89
  for (const store of stores) {
87
90
  store.headersAt = headersAt;
91
+ store.httpStatus = response.status;
88
92
  store.refused = refusedStatus(response.status);
89
93
  if (wait !== undefined) store.retryAfterMs = wait;
90
94
  }
@@ -40,7 +40,7 @@ const commonOptions = (profile: AiModelProfile, apiKey?: string) => ({
40
40
 
41
41
  export const createAiProvider = (profile: AiModelProfile, apiKey?: string, headers?: Record<string, string>): Provider => {
42
42
  if (profile.capabilities.includes("transcription")) throw new Error("Transcription profiles cannot be used for chat generation.");
43
- return withProviderErrors(completeThroughStream(createAdapter(profile, apiKey, headers)));
43
+ return withProviderErrors(completeThroughStream(createAdapter(profile, apiKey, headers)), profile.providerId);
44
44
  };
45
45
 
46
46
  const createAdapter = (profile: AiModelProfile, apiKey?: string, headers?: Record<string, string>): Provider => {
@@ -0,0 +1,36 @@
1
+ import { sql } from "bun";
2
+ import { recursiveGroupIdsSubquery } from "../services/accounts/group-sql";
3
+ import { toPgTextArray } from "../services/postgres";
4
+ import { hasBillableAiPricing } from "../shared/ai-costs";
5
+ import { readAiSettingsState } from "./settings";
6
+
7
+ /** One row per rule; identities are deduplicated when several model rules apply. */
8
+ export const aiPeopleAtLimit = async (db = sql): Promise<{ scope: string; people: string[] }[]> => {
9
+ const priced = [
10
+ ...new Set(
11
+ (await readAiSettingsState()).profiles.filter((profile) => hasBillableAiPricing(profile.pricing)).map((profile) => profile.modelId),
12
+ ),
13
+ ];
14
+ return db<{ scope: string; people: string[] }[]>`WITH rules AS MATERIALIZED (
15
+ SELECT r->>'scope' AS scope,r->'grants' AS grants,
16
+ (r->>'anchor')::timestamptz + floor(extract(epoch FROM (now()-(r->>'anchor')::timestamptz)) / ((r->>'hours')::int*3600)) * ((r->>'hours')::int*interval '1 hour') AS starts
17
+ FROM ai.cost_config,jsonb_array_elements(rules) r WHERE singleton AND enabled
18
+ AND (r->>'scope'='*' OR r->>'scope'=ANY(${toPgTextArray(priced)}::text[]))
19
+ ), people AS MATERIALIZED (
20
+ SELECT u.id FROM auth.users u WHERE EXISTS(SELECT 1 FROM ai.inference_calls WHERE kind='chat' AND user_id=u.id)
21
+ OR EXISTS(SELECT 1 FROM ai.conversations c JOIN ai.turns t ON t.conversation_id=c.id
22
+ WHERE c.created_by_user_id=u.id AND t.run_config->>'assistantChat'='true' AND t.run_config->'mandate' IS NULL)
23
+ ), allowances AS MATERIALIZED (
24
+ SELECT u.id,r.scope,r.starts,CASE WHEN bool_or(g->>'limit' IS NULL) FILTER(WHERE g IS NOT NULL) THEN NULL ELSE COALESCE(max((g->>'limit')::float8),0) END AS amount
25
+ FROM people u CROSS JOIN rules r LEFT JOIN LATERAL (
26
+ SELECT g FROM jsonb_array_elements(r.grants) g WHERE g->'principal'->>'type'='authenticated'
27
+ OR g->'principal'->>'userId'=u.id::text OR (g->'principal'->>'groupId')::uuid IN (${recursiveGroupIdsSubquery(sql`u.id`)})
28
+ ) grants ON true GROUP BY u.id,r.scope,r.starts
29
+ ), exhausted AS (
30
+ SELECT a.scope,a.id FROM allowances a LEFT JOIN ai.inference_calls c ON c.user_id=a.id AND c.kind='chat'
31
+ AND (a.scope='*' OR c.model_id=a.scope) AND c.started_at>=a.starts
32
+ AND c.started_at>COALESCE((SELECT max(created_at) FROM ai.cost_resets z WHERE z.user_id=a.id AND z.scope=a.scope),'-infinity'::timestamptz)
33
+ WHERE a.amount IS NOT NULL AND NOT EXISTS(SELECT 1 FROM allowances w WHERE w.id=a.id AND w.scope='*' AND w.amount IS NULL)
34
+ GROUP BY a.scope,a.id,a.amount HAVING COALESCE(sum(c.cost),0)>=a.amount
35
+ ) SELECT scope,array_agg(id::text ORDER BY id) AS people FROM exhausted GROUP BY scope`;
36
+ };
@@ -3,6 +3,7 @@ import { estimateTokens, type Provider } from "@k2b/nessi";
3
3
  import { sql } from "bun";
4
4
  import type { AccessSubject } from "../server/services/access";
5
5
  import { logger } from "../services/logging";
6
+ import type { AiResolvedModelRef } from "../shared/ai-models";
6
7
  import { isAssistantChatTurn } from "./assistant-models";
7
8
  import { AiBackgroundAdmissionError, type AiCallContext, type AiCallDetails, beginAiCall, finishAiCall } from "./inference-calls";
8
9
  import { type ProviderFetchMarks, runWithProviderFetchMarks } from "./provider-fetch";
@@ -230,7 +231,7 @@ export function inferenceProvider(
230
231
  firstByteMs: marks.firstByteAt === undefined ? undefined : marks.firstByteAt - requestStartedAt,
231
232
  firstBlockMs: marks.firstBlockAt === undefined ? undefined : marks.firstBlockAt - requestStartedAt,
232
233
  });
233
- } catch (error) {
234
+ } catch {
234
235
  log.warn("Chat usage booking failed", { code: "quota_booking_failed", turnId: context.turnId, callId: id });
235
236
  }
236
237
  }
@@ -246,8 +247,9 @@ export function assistantQuotaProvider(
246
247
  profile: AiModelProfile,
247
248
  turnId: string,
248
249
  conversationId: string,
250
+ modelRef: AiResolvedModelRef,
249
251
  ) {
250
- const common = { subject, turnId, conversationId };
252
+ const common = { subject, turnId, conversationId, modelRef };
251
253
  return inferenceProvider(
252
254
  provider,
253
255
  profile,
@@ -4,18 +4,21 @@ import { recursiveGroupIdsSubquery } from "../services/accounts/group-sql";
4
4
  import { toPgTextArray } from "../services/postgres";
5
5
  import { hasBillableAiPricing } from "../shared/ai-costs";
6
6
  import { type AiQuotaReport, type AiQuotaReportQuery, AiQuotaReportQuerySchema } from "../shared/ai-quotas";
7
+ import { aiPeopleAtLimit } from "./quota-limits";
7
8
  import { aiQuotas } from "./quotas";
8
9
  import { readAiSettingsState } from "./settings";
9
10
 
10
11
  /** Resolve display metadata only for administration, never on the inference path. */
11
12
  export async function quotaAdminConfig() {
12
13
  const config = await aiQuotas.config();
14
+ const limits = await aiPeopleAtLimit();
13
15
  const grants = await resolveDisplayNames(config.rules.flatMap((rule) => rule.grants));
14
16
  let offset = 0;
15
17
  return {
16
18
  ...config,
17
19
  rules: config.rules.map((rule) => ({
18
20
  ...rule,
21
+ peopleAtLimit: limits.find((limit) => limit.scope === rule.scope)?.people.length ?? 0,
19
22
  grants: rule.grants.map(() => {
20
23
  const grant = grants[offset++]!;
21
24
  return {
@@ -50,7 +53,7 @@ export async function quotaReport(input: Partial<AiQuotaReportQuery> = {}, now =
50
53
  page: 1,
51
54
  perPage: 25,
52
55
  };
53
- const pricedModels = (await readAiSettingsState()).profiles.filter((p) => hasBillableAiPricing(p.pricing)).map((p) => p.id);
56
+ const pricedModels = (await readAiSettingsState()).profiles.filter((p) => hasBillableAiPricing(p.pricing)).map((p) => p.modelId);
54
57
  const bucket = query.range === "24h" ? "hour" : "day";
55
58
  const order = query.sort === "label" ? sql`label` : query.sort === "cost" ? sql`cost` : sql`last_used`;
56
59
  const direction = query.direction === "asc" ? sql`ASC` : sql`DESC`;
@@ -80,7 +83,7 @@ export async function quotaReport(input: Partial<AiQuotaReportQuery> = {}, now =
80
83
  FROM ai.inference_calls c WHERE c.kind='chat' AND c.started_at<=${now}
81
84
  ), periods AS MATERIALIZED (
82
85
  SELECT * FROM calls WHERE started_at>=${since} AND started_at<${until}
83
- AND (${query.model}='' OR model_profile_id=${query.model})
86
+ AND (${query.model}='' OR model_id=${query.model})
84
87
  ), allowances AS MATERIALIZED (
85
88
  SELECT a.id,a.type,r.scope,
86
89
  CASE WHEN bool_or((g->>'limit') IS NULL) FILTER(WHERE g IS NOT NULL) THEN NULL ELSE COALESCE(max((g->>'limit')::float8),0) END AS amount,
@@ -95,7 +98,7 @@ export async function quotaReport(input: Partial<AiQuotaReportQuery> = {}, now =
95
98
  ), balances AS MATERIALIZED (
96
99
  SELECT a.*,COALESCE(sum(c.cost),0)::float8 AS used,count(c.id) FILTER(WHERE c.unknown)::int AS unknown
97
100
  FROM allowances a LEFT JOIN calls c ON c.identity_id=a.id AND c.identity_type=a.type
98
- AND (a.scope='*' OR c.model_profile_id=a.scope) AND c.started_at>=a.starts
101
+ AND (a.scope='*' OR c.model_id=a.scope) AND c.started_at>=a.starts
99
102
  AND c.started_at>COALESCE((SELECT max(created_at) FROM ai.cost_resets z
100
103
  WHERE COALESCE(z.user_id,z.service_account_id)=a.id AND (z.user_id IS NOT NULL)=(a.type='user') AND z.scope=a.scope),'-infinity'::timestamptz)
101
104
  GROUP BY a.id,a.type,a.scope,a.amount,a.starts
@@ -111,7 +114,7 @@ export async function quotaReport(input: Partial<AiQuotaReportQuery> = {}, now =
111
114
  SELECT a.id,a.type,a.label,
112
115
  CASE WHEN NOT COALESCE((SELECT enabled FROM config),false) THEN 'disabled' ELSE COALESCE(s.status,'unlimited') END AS status,
113
116
  COALESCE(s.scopes,0) AS scopes,CASE WHEN s.status='unlimited' OR NOT COALESCE((SELECT enabled FROM config),false) THEN 0 ELSE COALESCE(s.exhausted,0) END AS exhausted,
114
- (SELECT max(started_at) FROM calls c WHERE c.identity_id=a.id AND c.identity_type=a.type AND (${query.model}='' OR c.model_profile_id=${query.model})) AS last_used,
117
+ (SELECT max(started_at) FROM calls c WHERE c.identity_id=a.id AND c.identity_type=a.type AND (${query.model}='' OR c.model_id=${query.model})) AS last_used,
115
118
  sum(p.cost)::float8 AS cost,COALESCE(sum(p.input),0)::float8 AS input,COALESCE(sum(p.output),0)::float8 AS output,
116
119
  count(p.id)::int AS calls,count(p.id) FILTER(WHERE p.input IS NOT NULL AND p.output IS NOT NULL)::int AS measured,count(p.id) FILTER(WHERE p.estimated)::int AS estimated,count(p.id) FILTER(WHERE p.unknown)::int AS unknown
117
120
  FROM identities a LEFT JOIN states s ON s.id=a.id AND s.type=a.type
package/src/ai/quotas.ts CHANGED
@@ -3,6 +3,7 @@ import { type AccessSubject, buildAccessPrincipalCondition } from "../server/ser
3
3
  import { hasBillableAiPricing } from "../shared/ai-costs";
4
4
  import { type AiQuotaConfig, AiQuotaConfigSchema, type AiQuotaIdentity, type AiQuotaSnapshot } from "../shared/ai-quotas";
5
5
  import { readAiSettingsState } from "./settings";
6
+ import type { AiSettingsState } from "./types";
6
7
 
7
8
  export class AiQuotaError extends Error {
8
9
  constructor(
@@ -43,10 +44,18 @@ export const aiQuotas = {
43
44
  return { ...config, revision };
44
45
  });
45
46
  },
46
- async snapshot(subject: AccessSubject, model?: string, now = new Date(), db: SQL = sql): Promise<AiQuotaSnapshot> {
47
- const config = await aiQuotas.config(db);
47
+ async snapshot(
48
+ subject: AccessSubject,
49
+ model?: string,
50
+ now = new Date(),
51
+ db: SQL = sql,
52
+ context?: { state: AiSettingsState; config: AiQuotaConfig },
53
+ ): Promise<AiQuotaSnapshot> {
54
+ const config = context?.config ?? (await aiQuotas.config(db));
48
55
  const identity = ids(subject);
49
- const pricedModels = new Set((await readAiSettingsState()).profiles.filter((p) => hasBillableAiPricing(p.pricing)).map((p) => p.id));
56
+ const pricedModels = new Set(
57
+ (context?.state ?? (await readAiSettingsState())).profiles.filter((p) => hasBillableAiPricing(p.pricing)).map((p) => p.modelId),
58
+ );
50
59
  const matches = buildAccessPrincipalCondition({
51
60
  subject,
52
61
  columns: {
@@ -76,7 +85,7 @@ export const aiQuotas = {
76
85
  (c.finished_at IS NOT NULL OR (c.lease_expires_at <= now() OR (c.turn_id IS NOT NULL AND NOT EXISTS(SELECT 1 FROM ai.turns t WHERE t.id=c.turn_id AND t.attempt=c.turn_attempt AND t.status='running' AND t.lease_expires_at>now())))))::int AS unknown
77
86
  FROM ai.inference_calls c
78
87
  WHERE c.kind='chat' AND c.user_id IS NOT DISTINCT FROM ${identity.user}::uuid AND c.service_account_id IS NOT DISTINCT FROM ${identity.service}::uuid
79
- AND (${rule.scope}='*' OR c.model_profile_id=${rule.scope})
88
+ AND (${rule.scope}='*' OR c.model_id=${rule.scope})
80
89
  AND c.started_at>=${window.from} AND c.started_at<${window.until}
81
90
  AND c.started_at>COALESCE((SELECT max(r.created_at) FROM ai.cost_resets r
82
91
  WHERE r.user_id IS NOT DISTINCT FROM ${identity.user}::uuid AND r.service_account_id IS NOT DISTINCT FROM ${identity.service}::uuid AND r.scope=${rule.scope}),'-infinity'::timestamptz)`;
@@ -110,13 +119,15 @@ export const aiQuotas = {
110
119
  GROUP BY model_profile_id ORDER BY model_profile_id`;
111
120
  return { enabled: config.enabled, unit: config.unit ?? "EUR", balances, usage };
112
121
  },
113
- async assertAllowed(subject: AccessSubject, model: string) {
122
+ async assertAllowed(subject: AccessSubject, variantId: string, providedState?: AiSettingsState) {
123
+ const state = providedState ?? (await readAiSettingsState());
124
+ const profile = state.profiles.find((profile) => profile.id === variantId);
125
+ if (!profile || !hasBillableAiPricing(profile.pricing)) return false;
126
+ const model = profile.modelId;
114
127
  // Fast default path: no additional identity/usage queries on unrestricted installations.
115
- const pricing = (await readAiSettingsState()).profiles.find((profile) => profile.id === model)?.pricing;
116
- if (!hasBillableAiPricing(pricing)) return false;
117
128
  const config = await aiQuotas.config();
118
129
  if (!config.enabled || !config.rules.some((r) => r.scope === "*" || r.scope === model)) return false;
119
- const snapshot = await aiQuotas.snapshot(subject, model);
130
+ const snapshot = await aiQuotas.snapshot(subject, model, undefined, undefined, { state, config });
120
131
  for (const b of snapshot.balances) {
121
132
  if (b.bypassed || b.limit === null) continue;
122
133
  if (b.unknown)
@@ -26,6 +26,8 @@ import type { AiConversationService, AiRecentResource } from "./types";
26
26
  export const AiRecentResourceSchema: z.ZodType<AiRecentResource> = z
27
27
  .object({
28
28
  ref: CloudResourceRefSchema,
29
+ context: LinkedCloudResourceReferenceSchema.shape.context,
30
+ status: LinkedCloudResourceReferenceSchema.shape.status,
29
31
  title: LinkedCloudResourceReferenceSchema.shape.title.unwrap(),
30
32
  icon: LinkedCloudResourceReferenceSchema.shape.icon,
31
33
  href: LinkedCloudResourceReferenceSchema.shape.href,
@@ -183,6 +185,8 @@ export const listAiRecentResources = async (input: {
183
185
  ref,
184
186
  count,
185
187
  title: data.title,
188
+ context: ownRef?.context,
189
+ status: ownRef?.status,
186
190
  icon: ownRef?.icon ?? data.icon,
187
191
  href,
188
192
  preview: recentResourcePreview(ownRef?.preview ?? data.description),
@@ -229,6 +233,8 @@ export const listAiRecentResources = async (input: {
229
233
  ref,
230
234
  count,
231
235
  title: ownRef.title,
236
+ context: ownRef.context,
237
+ status: ownRef.status,
232
238
  icon: (ownRef.icon ?? type.icon ?? app.appIcon)?.slice(0, 120),
233
239
  href: ownRef.href,
234
240
  kind: type.title.slice(0, 120),