@k2b/cloud 0.43.0 → 0.45.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 (308) hide show
  1. package/package.json +11 -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/GroupCoverage.tsx +20 -5
  21. package/src/access/PermissionEditor.tsx +20 -5
  22. package/src/access/PrincipalAvatar.tsx +30 -21
  23. package/src/access/PrincipalPicker.tsx +14 -4
  24. package/src/access/ResourceApiKeys.tsx +3 -4
  25. package/src/access/app-access-messages.ts +6 -0
  26. package/src/access/messages.ts +4 -4
  27. package/src/account/EntitySearch.tsx +12 -0
  28. package/src/account/ProfilePictureDialog.tsx +699 -0
  29. package/src/account/profile-picture-messages.ts +269 -0
  30. package/src/account/ui.ts +7 -0
  31. package/src/ai/admin.ts +3 -0
  32. package/src/ai/app-frame.ts +12 -0
  33. package/src/ai/application-tasks.ts +101 -0
  34. package/src/ai/assistant-models.ts +126 -64
  35. package/src/ai/capabilities.ts +19 -4
  36. package/src/ai/chart-block.ts +63 -30
  37. package/src/ai/chat/chart-block.tsx +12 -3
  38. package/src/ai/chat/composer-adapter.ts +8 -7
  39. package/src/ai/chat/message-actions.tsx +1 -1
  40. package/src/ai/chat/message-utils.ts +2 -2
  41. package/src/ai/chat/presentation.tsx +22 -1
  42. package/src/ai/chat-quotas.ts +5 -4
  43. package/src/ai/chat-tasks.ts +2 -2
  44. package/src/ai/client/controller.ts +11 -10
  45. package/src/ai/client/projection.ts +3 -3
  46. package/src/ai/compaction.ts +3 -3
  47. package/src/ai/credentials.ts +13 -114
  48. package/src/ai/default-tool-contracts.ts +4 -3
  49. package/src/ai/dictation-runtime.ts +10 -6
  50. package/src/ai/enrich.ts +2 -2
  51. package/src/ai/executor.ts +40 -25
  52. package/src/ai/http.ts +17 -29
  53. package/src/ai/index.ts +12 -5
  54. package/src/ai/inference-calls.ts +5 -4
  55. package/src/ai/maintenance.ts +1 -1
  56. package/src/ai/memory-learning-runs.ts +5 -5
  57. package/src/ai/memory-learning.ts +3 -3
  58. package/src/ai/message-queue.ts +1 -2
  59. package/src/ai/migrate.ts +75 -42
  60. package/src/ai/model-access-migrate.ts +3 -17
  61. package/src/ai/model-access.ts +44 -85
  62. package/src/ai/model-card.tsx +134 -0
  63. package/src/ai/model-catalog.ts +359 -0
  64. package/src/ai/model-chooser-messages.ts +88 -0
  65. package/src/ai/model-chooser.tsx +577 -0
  66. package/src/ai/model-delete.ts +234 -0
  67. package/src/ai/model-fixture.ts +39 -0
  68. package/src/ai/model-icon.tsx +36 -0
  69. package/src/ai/model-overview.ts +53 -0
  70. package/src/ai/model-usage.ts +45 -0
  71. package/src/ai/openai-api-messages.ts +0 -4
  72. package/src/ai/openai-api.ts +26 -9
  73. package/src/ai/personal-agent.ts +1 -1
  74. package/src/ai/prefs.ts +12 -10
  75. package/src/ai/project-tool.ts +4 -4
  76. package/src/ai/projects-authorization.ts +53 -0
  77. package/src/ai/projects-routes.ts +58 -47
  78. package/src/ai/projects.ts +110 -88
  79. package/src/ai/protocol.ts +2 -2
  80. package/src/ai/provider-connections.ts +161 -0
  81. package/src/ai/provider-errors.ts +55 -6
  82. package/src/ai/provider-fetch.ts +4 -0
  83. package/src/ai/provider.ts +1 -1
  84. package/src/ai/quota-limits.ts +36 -0
  85. package/src/ai/quota-provider.ts +4 -2
  86. package/src/ai/quota-report.ts +7 -4
  87. package/src/ai/quotas.ts +19 -8
  88. package/src/ai/recent-resources.ts +9 -3
  89. package/src/ai/request-headers.ts +14 -87
  90. package/src/ai/resource-refs.ts +6 -2
  91. package/src/ai/routes.ts +47 -48
  92. package/src/ai/runtime.ts +50 -22
  93. package/src/ai/settings.ts +93 -580
  94. package/src/ai/skill-tool.ts +4 -4
  95. package/src/ai/skills-routes.ts +27 -26
  96. package/src/ai/skills.ts +120 -81
  97. package/src/ai/store.ts +45 -23
  98. package/src/ai/stream.ts +1 -1
  99. package/src/ai/structured-runs.ts +2 -2
  100. package/src/ai/structured.ts +21 -44
  101. package/src/ai/task-contracts.ts +25 -5
  102. package/src/ai/task-execution.ts +15 -6
  103. package/src/ai/task-model.ts +9 -0
  104. package/src/ai/transcription.ts +27 -22
  105. package/src/ai/types.ts +50 -62
  106. package/src/ai/ui.tsx +13 -0
  107. package/src/ai/usage.ts +8 -8
  108. package/src/ai/validate.ts +8 -7
  109. package/src/ai/view-block.ts +2 -2
  110. package/src/api/accounts-entities.ts +22 -3
  111. package/src/api/admin-ai-quotas.ts +34 -56
  112. package/src/api/admin-ai-skills.ts +4 -1
  113. package/src/api/admin-ai-usage.ts +1 -1
  114. package/src/api/admin-ai.ts +173 -0
  115. package/src/api/admin-core-settings.ts +68 -244
  116. package/src/api/app-approval.ts +3 -1
  117. package/src/api/app-event-delivery.ts +51 -0
  118. package/src/api/app-events.ts +123 -0
  119. package/src/api/app-resource-access.ts +48 -0
  120. package/src/api/auth/account-mismatch.ts +24 -0
  121. package/src/api/auth.ts +35 -5
  122. package/src/api/capabilities.ts +28 -4
  123. package/src/api/index.ts +7 -1
  124. package/src/api/mcp.ts +175 -2
  125. package/src/api/me.ts +6 -4
  126. package/src/api/pwa-phone.ts +75 -26
  127. package/src/api/pwa.ts +34 -8
  128. package/src/api/resource-links.ts +83 -6
  129. package/src/api/resource-resolve.ts +50 -0
  130. package/src/api/search/schemas.ts +3 -1
  131. package/src/api/search.ts +9 -7
  132. package/src/app-pwa/contracts.ts +4 -0
  133. package/src/app-pwa/core-query-error.ts +20 -0
  134. package/src/app-pwa/index.ts +1 -0
  135. package/src/app-pwa/service.ts +91 -0
  136. package/src/app-pwa/transport.ts +22 -0
  137. package/src/approvals/messages.ts +2 -2
  138. package/src/browser/cloud-id.ts +86 -0
  139. package/src/browser/resource-clipboard.ts +4 -0
  140. package/src/browser/resource-links.ts +52 -0
  141. package/src/browser/session.ts +39 -0
  142. package/src/browser/shared-text-history-validation.ts +151 -0
  143. package/src/browser/shared-text-history.ts +464 -0
  144. package/src/browser/shared-text-offline-authorized.ts +29 -0
  145. package/src/browser/shared-text-offline.ts +351 -0
  146. package/src/browser/shared-text-provider.ts +714 -0
  147. package/src/browser/shared-text-table-contract.ts +150 -0
  148. package/src/browser/shared-text.ts +19 -0
  149. package/src/capabilities/server-invoke.ts +2 -11
  150. package/src/capabilities/testing.ts +5 -2
  151. package/src/cli/access.ts +6 -5
  152. package/src/cli/admin/ai-models.ts +199 -0
  153. package/src/cli/admin/ai-quotas.ts +6 -102
  154. package/src/cli/admin/ai-usage.ts +4 -4
  155. package/src/cli/admin/app-credentials.ts +5 -3
  156. package/src/cli/admin/index.ts +5 -2
  157. package/src/cli/apps.ts +89 -26
  158. package/src/cli/capabilities.ts +22 -1
  159. package/src/cli/events.ts +320 -0
  160. package/src/config/env.ts +40 -0
  161. package/src/contracts/app-access.ts +26 -2
  162. package/src/contracts/app-details.ts +89 -0
  163. package/src/contracts/app-events.ts +51 -0
  164. package/src/contracts/app.ts +21 -5
  165. package/src/contracts/capabilities.ts +143 -3
  166. package/src/contracts/index.ts +2 -0
  167. package/src/contracts/notification-types.ts +36 -9
  168. package/src/contracts/pwa.ts +17 -1
  169. package/src/contracts/registry.ts +6 -0
  170. package/src/contracts/resource-clipboard.ts +35 -0
  171. package/src/contracts/resource-pages.ts +140 -0
  172. package/src/contracts/resource-resolution.ts +13 -0
  173. package/src/contracts/shared.ts +6 -1
  174. package/src/contracts/user-notifications.ts +2 -0
  175. package/src/events/app-event-delivery.ts +100 -0
  176. package/src/events/app-events.testing.ts +65 -0
  177. package/src/events/app-events.ts +134 -0
  178. package/src/events/core-user-created.ts +24 -0
  179. package/src/events/index.ts +9 -0
  180. package/src/events/testing.ts +2 -0
  181. package/src/index.ts +1 -1
  182. package/src/server/api/respond.ts +17 -4
  183. package/src/server/index.ts +2 -0
  184. package/src/server/middleware/app-access.ts +1 -1
  185. package/src/server/middleware/auth.ts +21 -7
  186. package/src/server/middleware/index.ts +1 -0
  187. package/src/server/middleware/invocation.ts +2 -0
  188. package/src/server/public-storage.ts +106 -0
  189. package/src/server/services/access.ts +31 -10
  190. package/src/server/services/index.ts +1 -0
  191. package/src/services/accounts/app.ts +36 -13
  192. package/src/services/accounts/entities.ts +43 -19
  193. package/src/services/accounts/group-sql.ts +23 -0
  194. package/src/services/accounts/groups.ts +19 -14
  195. package/src/services/accounts/identities.ts +70 -11
  196. package/src/services/accounts/identity-reconciliation.ts +3 -1
  197. package/src/services/accounts/local-groups.ts +69 -4
  198. package/src/services/accounts/users.ts +45 -14
  199. package/src/services/app-access.ts +25 -7
  200. package/src/services/app-details.ts +224 -0
  201. package/src/services/app-discovery.ts +2 -1
  202. package/src/services/app-event-shapes.ts +139 -0
  203. package/src/services/app-events/accept.ts +116 -0
  204. package/src/services/app-events/admin.ts +250 -0
  205. package/src/services/app-events/declarations.ts +187 -0
  206. package/src/services/app-events/deliver.ts +218 -0
  207. package/src/services/app-events.ts +92 -0
  208. package/src/services/config-conflict.ts +11 -0
  209. package/src/services/core-user-events.ts +1 -0
  210. package/src/services/freeipa-config.ts +9 -0
  211. package/src/services/geo/ledger.ts +28 -10
  212. package/src/services/geo/service.ts +43 -24
  213. package/src/services/identity/core-origin.ts +12 -0
  214. package/src/services/identity/invocation-actor.ts +20 -2
  215. package/src/services/identity/invocation-authority.ts +4 -0
  216. package/src/services/identity/invocation-token.ts +238 -56
  217. package/src/services/identity/issue-invocation.ts +6 -1
  218. package/src/services/image-metadata.ts +65 -1
  219. package/src/services/index.ts +13 -1
  220. package/src/services/ipa/sync.ts +6 -2
  221. package/src/services/ipa/users.ts +4 -1
  222. package/src/services/mandates/index.ts +2 -2
  223. package/src/services/mandates/policy.ts +20 -12
  224. package/src/services/notifications/browser.ts +4 -0
  225. package/src/services/notifications/catalog.ts +5 -2
  226. package/src/services/notifications/platform.ts +7 -2
  227. package/src/services/notifications/user.ts +18 -7
  228. package/src/services/oauth-tokens.ts +28 -2
  229. package/src/services/object-store.ts +217 -0
  230. package/src/services/providers/local/users.ts +5 -0
  231. package/src/services/pwa-devices.ts +147 -37
  232. package/src/services/session/index.ts +26 -3
  233. package/src/services/settings/core-settings.ts +71 -43
  234. package/src/services/settings/index.ts +8 -0
  235. package/src/services/settings/versions.ts +26 -0
  236. package/src/shared/ai-models.ts +274 -0
  237. package/src/shared/ai-quotas.ts +2 -2
  238. package/src/shared/ai-usage.ts +2 -2
  239. package/src/shared/email-html.ts +1 -0
  240. package/src/shared/index.ts +2 -0
  241. package/src/shared/markdown/extensions/katex.ts +8 -1
  242. package/src/shared/markdown/extensions/tables.ts +14 -1
  243. package/src/shared/markdown/index.ts +22 -11
  244. package/src/shared-text/anchors.ts +29 -0
  245. package/src/shared-text/awareness.ts +87 -0
  246. package/src/shared-text/changes.ts +27 -0
  247. package/src/shared-text/documents.ts +433 -65
  248. package/src/shared-text/events.ts +12 -2
  249. package/src/shared-text/index.ts +16 -1
  250. package/src/shared-text/log.ts +24 -5
  251. package/src/shared-text/snapshots.ts +69 -23
  252. package/src/shared-text/structural-history.ts +377 -0
  253. package/src/shared-text/table-actions.ts +432 -0
  254. package/src/shared-text/text-diff.ts +75 -0
  255. package/src/shared-text/types.ts +24 -5
  256. package/src/ssr/AdminArea.tsx +131 -0
  257. package/src/ssr/AdminLayout.tsx +9 -3
  258. package/src/ssr/AdminSidebar.tsx +7 -7
  259. package/src/ssr/AppAccessDenied.tsx +2 -0
  260. package/src/ssr/AppBadge.tsx +14 -0
  261. package/src/ssr/AppLaunchpadPanel.tsx +2 -2
  262. package/src/ssr/CloudShell.island.tsx +44 -7
  263. package/src/ssr/Layout.tsx +9 -7
  264. package/src/ssr/MobileProfileActions.tsx +2 -2
  265. package/src/ssr/ProfilePreferences.tsx +2 -1
  266. package/src/ssr/PwaLayout.tsx +16 -1
  267. package/src/ssr/PwaPartCount.island.tsx +23 -0
  268. package/src/ssr/SettingsSaveOutcome.tsx +58 -0
  269. package/src/ssr/SettingsSaveStatus.island.tsx +16 -0
  270. package/src/ssr/__snapshots__/Layout.unchanged.render.test.tsx.snap +3 -3
  271. package/src/ssr/admin-active-link.ts +16 -0
  272. package/src/ssr/admin-navigation.ts +26 -31
  273. package/src/ssr/app-badges.ts +31 -7
  274. package/src/ssr/app-launchpad.tsx +6 -1
  275. package/src/ssr/index.ts +8 -0
  276. package/src/ssr/page-trail-runtime.ts +3 -1
  277. package/src/ssr/platform-messages.ts +11 -1
  278. package/src/ssr/profile-actions.ts +3 -6
  279. package/src/ssr/pwa-messages.ts +2 -2
  280. package/src/ssr/rail-navigation.ts +5 -1
  281. package/src/ssr/settings-draft.ts +153 -0
  282. package/src/ssr/shell-data.tsx +3 -1
  283. package/src/styles/global.css +1 -0
  284. package/src/styles/profile-picture.css +270 -0
  285. package/src/styles/utilities-navigation.css +6 -0
  286. package/src/workflows/ai/policy.ts +49 -0
  287. package/src/workflows/ai/runtime.ts +131 -21
  288. package/src/workflows/ai/store.ts +283 -35
  289. package/src/workflows/ai/types.ts +8 -2
  290. package/src/workflows/ai-actions.ts +148 -120
  291. package/src/workflows/authoring.ts +31 -0
  292. package/src/workflows/contracts.ts +1 -0
  293. package/src/workflows/definition.ts +2 -0
  294. package/src/workflows/editor.ts +15 -0
  295. package/src/workflows/index.ts +2 -1
  296. package/src/workflows/language/compiler.ts +34 -3
  297. package/src/workflows/language/expressions.ts +3 -2
  298. package/src/workflows/runtime/executor.ts +134 -34
  299. package/src/workflows/runtime/index.ts +2 -0
  300. package/src/workflows/runtime/ports.ts +5 -1
  301. package/src/workflows/store/actions.ts +1 -1
  302. package/src/workflows/store/definitions.ts +5 -0
  303. package/src/workflows/store/runs.ts +5 -3
  304. package/src/workflows/store/worker.ts +7 -2
  305. package/src/ai/model-pricing.ts +0 -43
  306. package/src/ai/model-request-settings.ts +0 -98
  307. package/src/ai/settings-messages.ts +0 -129
  308. package/src/shared/ai-model-request-settings.ts +0 -21
@@ -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,
@@ -121,9 +123,9 @@ export const listAiRecentResources = async (input: {
121
123
  const skillIds = candidates.flatMap(({ ref }) => (ref.type === "core.ai.skill" ? [ref.id] : []));
122
124
  // Exactly the sent Skills, so a large Skill catalog never pushes one out; failing, they are left out like a reader.
123
125
  const skills = skillIds.length
124
- ? withinBudget(signal, () =>
125
- (deps.skills ?? aiSkills).list(input.authority.accessSubject, { shortIds: skillIds, authority: input.authority }),
126
- ).catch(() => [])
126
+ ? withinBudget(signal, () => (deps.skills ?? aiSkills).list(input.authority, { shortIds: skillIds, authority: input.authority })).catch(
127
+ () => [],
128
+ )
127
129
  : Promise.resolve([]);
128
130
  const registry = candidates.some(({ ref }) => ref.type !== "core.ai.skill" && ref.type !== "assistant.artifact")
129
131
  ? withinBudget(signal, async () =>
@@ -183,6 +185,8 @@ export const listAiRecentResources = async (input: {
183
185
  ref,
184
186
  count,
185
187
  title: data.title,
188
+ context: ownRef?.context,
189
+ status: ownRef?.status,
186
190
  icon: ownRef?.icon ?? data.icon,
187
191
  href,
188
192
  preview: recentResourcePreview(ownRef?.preview ?? data.description),
@@ -229,6 +233,8 @@ export const listAiRecentResources = async (input: {
229
233
  ref,
230
234
  count,
231
235
  title: ownRef.title,
236
+ context: ownRef.context,
237
+ status: ownRef.status,
232
238
  icon: (ownRef.icon ?? type.icon ?? app.appIcon)?.slice(0, 120),
233
239
  href: ownRef.href,
234
240
  kind: type.title.slice(0, 120),
@@ -1,117 +1,44 @@
1
1
  import { sql } from "bun";
2
- import { toPgTextArray } from "../services/postgres";
3
2
  import { decryptValue, encryptValue } from "../services/settings/crypto";
4
- import {
5
- AI_REQUEST_HEADERS_PROVIDER_ERROR,
6
- AiRequestHeadersSchema,
7
- patchAiRequestHeaders,
8
- providerSupportsRequestHeaders,
9
- } from "../shared/ai-request-options";
10
- import type { AiModelProfile } from "./types";
3
+ import { AiRequestHeadersSchema, patchAiRequestHeaders } from "../shared/ai-request-options";
11
4
 
12
5
  type SqlClient = typeof sql;
13
- export type AiRequestHeaderPatch = { profileId: string; patch: unknown };
14
6
 
15
- const decryptHeaders = async (profileId: string, secret: string): Promise<Record<string, string>> => {
7
+ const decryptHeaders = async (providerId: string, secret: string): Promise<Record<string, string>> => {
16
8
  try {
17
9
  const parsed = AiRequestHeadersSchema.safeParse(await decryptValue(secret));
18
10
  if (parsed.success && Object.values(parsed.data).every((value) => typeof value === "string"))
19
11
  return patchAiRequestHeaders({}, parsed.data);
20
12
  } catch {}
21
- console.warn(`[ai] ignoring unreadable request headers for profile ${JSON.stringify(profileId)}`);
13
+ console.warn(`[ai] ignoring unreadable request headers for provider ${JSON.stringify(providerId)}`);
22
14
  return {};
23
15
  };
24
16
 
25
17
  /** Secrets stay server-side, in a separate encrypted row like provider API keys. */
26
- export const getAiRequestHeaders = async (profileId: string, db: SqlClient = sql): Promise<Record<string, string>> => {
27
- const [row] = await db<{ secret: string }[]>`SELECT secret FROM ai.model_request_headers WHERE profile_id = ${profileId}`;
28
- return row ? decryptHeaders(profileId, row.secret) : {};
18
+ export const getAiRequestHeaders = async (providerId: string, db: SqlClient = sql): Promise<Record<string, string>> => {
19
+ const [row] = await db<{ secret: string }[]>`SELECT secret FROM ai.provider_request_headers WHERE provider_id = ${providerId}`;
20
+ return row ? decryptHeaders(providerId, row.secret) : {};
29
21
  };
30
22
 
31
- export const setAiRequestHeaders = async (profileId: string, headers: Record<string, string>, db: SqlClient = sql): Promise<void> => {
23
+ export const setAiRequestHeaders = async (providerId: string, headers: Record<string, string>, db: SqlClient = sql): Promise<void> => {
32
24
  const normalized = patchAiRequestHeaders({}, headers);
33
25
  if (!Object.keys(normalized).length) {
34
- await db`DELETE FROM ai.model_request_headers WHERE profile_id = ${profileId}`;
26
+ await db`DELETE FROM ai.provider_request_headers WHERE provider_id = ${providerId}`;
35
27
  return;
36
28
  }
37
29
  const encrypted = await encryptValue(normalized);
38
- await db`INSERT INTO ai.model_request_headers (profile_id, secret) VALUES (${profileId}, ${encrypted})
39
- ON CONFLICT (profile_id) DO UPDATE SET secret = EXCLUDED.secret, updated_at = now()`;
30
+ await db`INSERT INTO ai.provider_request_headers (provider_id, secret) VALUES (${providerId}, ${encrypted})
31
+ ON CONFLICT (provider_id) DO UPDATE SET secret = EXCLUDED.secret, updated_at = now()`;
40
32
  };
41
33
 
42
- /** Admin reads contain names only. Never serialize header values into profiles. */
34
+ /** Admin reads contain names only. Never serialize header values into providers. */
43
35
  export const listAiRequestHeaderNames = async (db: SqlClient = sql): Promise<Record<string, string[]>> => {
44
- const rows = await db<{ profile_id: string; secret: string }[]>`SELECT profile_id, secret FROM ai.model_request_headers`;
36
+ const rows = await db<{ provider_id: string; secret: string }[]>`SELECT provider_id, secret FROM ai.provider_request_headers`;
45
37
  const names: Record<string, string[]> = {};
46
38
  for (const row of rows)
47
- Object.defineProperty(names, row.profile_id, {
48
- value: Object.keys(await decryptHeaders(row.profile_id, row.secret)).sort(),
39
+ Object.defineProperty(names, row.provider_id, {
40
+ value: Object.keys(await decryptHeaders(row.provider_id, row.secret)).sort(),
49
41
  enumerable: true,
50
42
  });
51
43
  return names;
52
44
  };
53
-
54
- export const pruneAiRequestHeaders = async (keepProfileIds: readonly string[], db: SqlClient = sql): Promise<void> => {
55
- if (!keepProfileIds.length) await db`DELETE FROM ai.model_request_headers`;
56
- else await db`DELETE FROM ai.model_request_headers WHERE profile_id <> ALL(${toPgTextArray([...keepProfileIds])}::text[])`;
57
- };
58
-
59
- /** Follow credentials: changing provider discards stored secrets; omission preserves them on the same provider. */
60
- export const planAiProfileRequestHeaders = (input: {
61
- currentProfiles: readonly AiModelProfile[];
62
- nextProfiles: readonly AiModelProfile[];
63
- existingNames: Record<string, string[]>;
64
- submitted: readonly AiRequestHeaderPatch[];
65
- }): { keepHeaderProfileIds: string[]; patches: { profileId: string; patch: Record<string, string | null> }[]; error?: string } => {
66
- const current = new Map(input.currentProfiles.map((profile) => [profile.id, profile]));
67
- const keepHeaderProfileIds = input.nextProfiles
68
- .filter(
69
- (profile) =>
70
- providerSupportsRequestHeaders(profile.provider) &&
71
- current.get(profile.id)?.provider === profile.provider &&
72
- !profile.capabilities.includes("transcription"),
73
- )
74
- .map((profile) => profile.id);
75
- const patches: { profileId: string; patch: Record<string, string | null> }[] = [];
76
- for (const submitted of input.submitted) {
77
- const profile = input.nextProfiles.find((profile) => profile.id === submitted.profileId);
78
- const parsed = AiRequestHeadersSchema.safeParse(submitted.patch);
79
- if (!parsed.success)
80
- return { keepHeaderProfileIds: [], patches: [], error: `requestHeaders: ${zodHeaderMessage(parsed.error.issues)}` };
81
- if (!profile || !providerSupportsRequestHeaders(profile.provider))
82
- return { keepHeaderProfileIds: [], patches: [], error: AI_REQUEST_HEADERS_PROVIDER_ERROR };
83
- if (profile.capabilities.includes("transcription"))
84
- return {
85
- keepHeaderProfileIds: [],
86
- patches: [],
87
- error: "requestHeaders: Request settings are not supported on transcription profiles.",
88
- };
89
- const oldNames =
90
- keepHeaderProfileIds.includes(profile.id) && Object.hasOwn(input.existingNames, profile.id) ? input.existingNames[profile.id]! : [];
91
- try {
92
- patchAiRequestHeaders(Object.fromEntries(oldNames.map((name) => [name, ""])), parsed.data);
93
- } catch {
94
- return {
95
- keepHeaderProfileIds: [],
96
- patches: [],
97
- error: "requestHeaders: At most 32 extra headers are allowed after applying the patch.",
98
- };
99
- }
100
- patches.push({ profileId: profile.id, patch: parsed.data });
101
- }
102
- return { keepHeaderProfileIds, patches };
103
- };
104
-
105
- const zodHeaderMessage = (issues: readonly { path: readonly PropertyKey[]; message: string }[]) =>
106
- issues.map((issue) => `${issue.path.map(String).join(".") || "headers"}: ${issue.message}`).join("; ");
107
-
108
- /** Apply after pruning so a provider change cannot inherit old secrets. Call inside the settings transaction. */
109
- export const storeAiRequestHeaderPlan = async (
110
- plan: ReturnType<typeof planAiProfileRequestHeaders>,
111
- db: SqlClient = sql,
112
- ): Promise<void> => {
113
- if (plan.error) throw new Error(plan.error);
114
- await pruneAiRequestHeaders(plan.keepHeaderProfileIds, db);
115
- for (const { profileId, patch } of plan.patches)
116
- await setAiRequestHeaders(profileId, patchAiRequestHeaders(await getAiRequestHeaders(profileId, db), patch), db);
117
- };