@oneuptime/common 11.3.24 → 11.3.26

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 (189) hide show
  1. package/Models/DatabaseModels/AIConversation.ts +322 -0
  2. package/Models/DatabaseModels/AIConversationMessage.ts +495 -0
  3. package/Models/DatabaseModels/AIRun.ts +584 -0
  4. package/Models/DatabaseModels/AIRunEvent.ts +443 -0
  5. package/Models/DatabaseModels/Index.ts +8 -0
  6. package/Models/DatabaseModels/LlmLog.ts +26 -0
  7. package/Models/DatabaseModels/LlmProvider.ts +2 -2
  8. package/Server/API/AIChatAPI.ts +693 -0
  9. package/Server/API/LlmProviderAPI.ts +169 -0
  10. package/Server/API/MicrosoftTeamsAPI.ts +5 -0
  11. package/Server/API/SlackAPI.ts +663 -0
  12. package/Server/Infrastructure/Postgres/SchemaMigrations/1783363279075-AddAIChatModels.ts +245 -0
  13. package/Server/Infrastructure/Postgres/SchemaMigrations/1783443471795-AddLlmProviderToAIConversation.ts +36 -0
  14. package/Server/Infrastructure/Postgres/SchemaMigrations/1783453297388-AddAIChatWriteActionsAndWidgets.ts +46 -0
  15. package/Server/Infrastructure/Postgres/SchemaMigrations/1783461767405-MigrationName.ts +119 -0
  16. package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +8 -0
  17. package/Server/Services/AIConversationMessageService.ts +39 -0
  18. package/Server/Services/AIConversationService.ts +63 -0
  19. package/Server/Services/AIRunEventService.ts +39 -0
  20. package/Server/Services/AIRunService.ts +39 -0
  21. package/Server/Services/AIService.ts +53 -31
  22. package/Server/Services/DatabaseService.ts +19 -0
  23. package/Server/Services/LlmProviderService.ts +110 -0
  24. package/Server/Services/ProjectService.ts +24 -0
  25. package/Server/Types/AnalyticsDatabase/ModelPermission.ts +74 -9
  26. package/Server/Utils/AI/AIChatPrivacyFilter.ts +28 -0
  27. package/Server/Utils/AI/Chat/ChatAgentRunner.ts +1054 -0
  28. package/Server/Utils/AI/Chat/ObservabilityAssistant.ts +239 -0
  29. package/Server/Utils/AI/Chat/ObservabilityChatPrompt.ts +51 -0
  30. package/Server/Utils/AI/Toolbox/AlertTools.ts +201 -0
  31. package/Server/Utils/AI/Toolbox/AlertWriteTools.ts +174 -0
  32. package/Server/Utils/AI/Toolbox/ContextTools.ts +189 -0
  33. package/Server/Utils/AI/Toolbox/ExceptionTools.ts +149 -0
  34. package/Server/Utils/AI/Toolbox/IncidentTools.ts +204 -0
  35. package/Server/Utils/AI/Toolbox/IncidentWriteTools.ts +350 -0
  36. package/Server/Utils/AI/Toolbox/Index.ts +228 -0
  37. package/Server/Utils/AI/Toolbox/LogTools.ts +339 -0
  38. package/Server/Utils/AI/Toolbox/MetricTools.ts +179 -0
  39. package/Server/Utils/AI/Toolbox/MonitorTools.ts +193 -0
  40. package/Server/Utils/AI/Toolbox/Serializer.ts +257 -0
  41. package/Server/Utils/AI/Toolbox/ToolTypes.ts +249 -0
  42. package/Server/Utils/AI/Toolbox/TraceTools.ts +402 -0
  43. package/Server/Utils/AI/Toolbox/WidgetBuilder.ts +189 -0
  44. package/Server/Utils/LLM/LLMService.ts +502 -92
  45. package/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeams.ts +512 -19
  46. package/Server/Utils/Workspace/Slack/Slack.ts +80 -0
  47. package/Server/Utils/Workspace/Slack/app-manifest.json +16 -1
  48. package/Tests/Server/Utils/AI/AIChatModelACL.test.ts +42 -0
  49. package/Tests/Server/Utils/AI/ChatAgentHelpers.test.ts +89 -0
  50. package/Tests/Server/Utils/AI/LLMServiceBaseUrl.test.ts +139 -0
  51. package/Tests/Server/Utils/AI/LLMServiceToolCalling.test.ts +308 -0
  52. package/Tests/Server/Utils/AI/ToolArgsGetTimeRange.test.ts +62 -0
  53. package/Tests/Server/Utils/AI/ToolArgsScopeServiceIds.test.ts +79 -0
  54. package/Tests/Server/Utils/AI/ToolResultSerializer.test.ts +155 -0
  55. package/Tests/Types/Billing/SubscriptionStatus.test.ts +127 -0
  56. package/Tests/Types/Metrics/RecordingRuleDefinition.test.ts +213 -0
  57. package/Types/AI/AIChatMessageRole.ts +6 -0
  58. package/Types/AI/AIChatMessageStatus.ts +32 -0
  59. package/Types/AI/AIChatPermissionMode.ts +69 -0
  60. package/Types/AI/AIChatTypes.ts +231 -0
  61. package/Types/AI/AIRunEventType.ts +17 -0
  62. package/Types/AI/AIRunStatus.ts +28 -0
  63. package/Types/AI/AIRunType.ts +6 -0
  64. package/Types/LLM/LlmType.ts +6 -0
  65. package/UI/Components/Header/HeaderIconDropdownButton.tsx +22 -12
  66. package/UI/Components/Markdown.tsx/MarkdownViewer.tsx +35 -3
  67. package/UI/Components/Page/Page.tsx +14 -0
  68. package/UI/Utils/LlmTypeDropdownOptions.ts +40 -0
  69. package/UI/Utils/TestLLMProvider.ts +59 -0
  70. package/build/dist/Models/DatabaseModels/AIConversation.js +345 -0
  71. package/build/dist/Models/DatabaseModels/AIConversation.js.map +1 -0
  72. package/build/dist/Models/DatabaseModels/AIConversationMessage.js +521 -0
  73. package/build/dist/Models/DatabaseModels/AIConversationMessage.js.map +1 -0
  74. package/build/dist/Models/DatabaseModels/AIRun.js +619 -0
  75. package/build/dist/Models/DatabaseModels/AIRun.js.map +1 -0
  76. package/build/dist/Models/DatabaseModels/AIRunEvent.js +469 -0
  77. package/build/dist/Models/DatabaseModels/AIRunEvent.js.map +1 -0
  78. package/build/dist/Models/DatabaseModels/Index.js +8 -0
  79. package/build/dist/Models/DatabaseModels/Index.js.map +1 -1
  80. package/build/dist/Models/DatabaseModels/LlmLog.js +28 -0
  81. package/build/dist/Models/DatabaseModels/LlmLog.js.map +1 -1
  82. package/build/dist/Models/DatabaseModels/LlmProvider.js +2 -2
  83. package/build/dist/Models/DatabaseModels/LlmProvider.js.map +1 -1
  84. package/build/dist/Server/API/AIChatAPI.js +498 -0
  85. package/build/dist/Server/API/AIChatAPI.js.map +1 -0
  86. package/build/dist/Server/API/LlmProviderAPI.js +107 -1
  87. package/build/dist/Server/API/LlmProviderAPI.js.map +1 -1
  88. package/build/dist/Server/API/MicrosoftTeamsAPI.js +4 -0
  89. package/build/dist/Server/API/MicrosoftTeamsAPI.js.map +1 -1
  90. package/build/dist/Server/API/SlackAPI.js +442 -0
  91. package/build/dist/Server/API/SlackAPI.js.map +1 -1
  92. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1783363279075-AddAIChatModels.js +96 -0
  93. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1783363279075-AddAIChatModels.js.map +1 -0
  94. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1783443471795-AddLlmProviderToAIConversation.js +25 -0
  95. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1783443471795-AddLlmProviderToAIConversation.js.map +1 -0
  96. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1783453297388-AddAIChatWriteActionsAndWidgets.js +31 -0
  97. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1783453297388-AddAIChatWriteActionsAndWidgets.js.map +1 -0
  98. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1783461767405-MigrationName.js +46 -0
  99. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1783461767405-MigrationName.js.map +1 -0
  100. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +8 -0
  101. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
  102. package/build/dist/Server/Services/AIConversationMessageService.js +34 -0
  103. package/build/dist/Server/Services/AIConversationMessageService.js.map +1 -0
  104. package/build/dist/Server/Services/AIConversationService.js +42 -0
  105. package/build/dist/Server/Services/AIConversationService.js.map +1 -0
  106. package/build/dist/Server/Services/AIRunEventService.js +34 -0
  107. package/build/dist/Server/Services/AIRunEventService.js.map +1 -0
  108. package/build/dist/Server/Services/AIRunService.js +34 -0
  109. package/build/dist/Server/Services/AIRunService.js.map +1 -0
  110. package/build/dist/Server/Services/AIService.js +31 -23
  111. package/build/dist/Server/Services/AIService.js.map +1 -1
  112. package/build/dist/Server/Services/DatabaseService.js +10 -0
  113. package/build/dist/Server/Services/DatabaseService.js.map +1 -1
  114. package/build/dist/Server/Services/LlmProviderService.js +108 -0
  115. package/build/dist/Server/Services/LlmProviderService.js.map +1 -1
  116. package/build/dist/Server/Services/ProjectService.js +25 -0
  117. package/build/dist/Server/Services/ProjectService.js.map +1 -1
  118. package/build/dist/Server/Types/AnalyticsDatabase/ModelPermission.js +50 -7
  119. package/build/dist/Server/Types/AnalyticsDatabase/ModelPermission.js.map +1 -1
  120. package/build/dist/Server/Utils/AI/AIChatPrivacyFilter.js +18 -0
  121. package/build/dist/Server/Utils/AI/AIChatPrivacyFilter.js.map +1 -0
  122. package/build/dist/Server/Utils/AI/Chat/ChatAgentRunner.js +745 -0
  123. package/build/dist/Server/Utils/AI/Chat/ChatAgentRunner.js.map +1 -0
  124. package/build/dist/Server/Utils/AI/Chat/ObservabilityAssistant.js +165 -0
  125. package/build/dist/Server/Utils/AI/Chat/ObservabilityAssistant.js.map +1 -0
  126. package/build/dist/Server/Utils/AI/Chat/ObservabilityChatPrompt.js +44 -0
  127. package/build/dist/Server/Utils/AI/Chat/ObservabilityChatPrompt.js.map +1 -0
  128. package/build/dist/Server/Utils/AI/Toolbox/AlertTools.js +167 -0
  129. package/build/dist/Server/Utils/AI/Toolbox/AlertTools.js.map +1 -0
  130. package/build/dist/Server/Utils/AI/Toolbox/AlertWriteTools.js +136 -0
  131. package/build/dist/Server/Utils/AI/Toolbox/AlertWriteTools.js.map +1 -0
  132. package/build/dist/Server/Utils/AI/Toolbox/ContextTools.js +141 -0
  133. package/build/dist/Server/Utils/AI/Toolbox/ContextTools.js.map +1 -0
  134. package/build/dist/Server/Utils/AI/Toolbox/ExceptionTools.js +117 -0
  135. package/build/dist/Server/Utils/AI/Toolbox/ExceptionTools.js.map +1 -0
  136. package/build/dist/Server/Utils/AI/Toolbox/IncidentTools.js +167 -0
  137. package/build/dist/Server/Utils/AI/Toolbox/IncidentTools.js.map +1 -0
  138. package/build/dist/Server/Utils/AI/Toolbox/IncidentWriteTools.js +280 -0
  139. package/build/dist/Server/Utils/AI/Toolbox/IncidentWriteTools.js.map +1 -0
  140. package/build/dist/Server/Utils/AI/Toolbox/Index.js +150 -0
  141. package/build/dist/Server/Utils/AI/Toolbox/Index.js.map +1 -0
  142. package/build/dist/Server/Utils/AI/Toolbox/LogTools.js +246 -0
  143. package/build/dist/Server/Utils/AI/Toolbox/LogTools.js.map +1 -0
  144. package/build/dist/Server/Utils/AI/Toolbox/MetricTools.js +120 -0
  145. package/build/dist/Server/Utils/AI/Toolbox/MetricTools.js.map +1 -0
  146. package/build/dist/Server/Utils/AI/Toolbox/MonitorTools.js +158 -0
  147. package/build/dist/Server/Utils/AI/Toolbox/MonitorTools.js.map +1 -0
  148. package/build/dist/Server/Utils/AI/Toolbox/Serializer.js +188 -0
  149. package/build/dist/Server/Utils/AI/Toolbox/Serializer.js.map +1 -0
  150. package/build/dist/Server/Utils/AI/Toolbox/ToolTypes.js +142 -0
  151. package/build/dist/Server/Utils/AI/Toolbox/ToolTypes.js.map +1 -0
  152. package/build/dist/Server/Utils/AI/Toolbox/TraceTools.js +309 -0
  153. package/build/dist/Server/Utils/AI/Toolbox/TraceTools.js.map +1 -0
  154. package/build/dist/Server/Utils/AI/Toolbox/WidgetBuilder.js +120 -0
  155. package/build/dist/Server/Utils/AI/Toolbox/WidgetBuilder.js.map +1 -0
  156. package/build/dist/Server/Utils/LLM/LLMService.js +372 -88
  157. package/build/dist/Server/Utils/LLM/LLMService.js.map +1 -1
  158. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeams.js +378 -15
  159. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeams.js.map +1 -1
  160. package/build/dist/Server/Utils/Workspace/Slack/Slack.js +59 -0
  161. package/build/dist/Server/Utils/Workspace/Slack/Slack.js.map +1 -1
  162. package/build/dist/Server/Utils/Workspace/Slack/app-manifest.json +16 -1
  163. package/build/dist/Types/AI/AIChatMessageRole.js +7 -0
  164. package/build/dist/Types/AI/AIChatMessageRole.js.map +1 -0
  165. package/build/dist/Types/AI/AIChatMessageStatus.js +27 -0
  166. package/build/dist/Types/AI/AIChatMessageStatus.js.map +1 -0
  167. package/build/dist/Types/AI/AIChatPermissionMode.js +53 -0
  168. package/build/dist/Types/AI/AIChatPermissionMode.js.map +1 -0
  169. package/build/dist/Types/AI/AIChatTypes.js +74 -0
  170. package/build/dist/Types/AI/AIChatTypes.js.map +1 -0
  171. package/build/dist/Types/AI/AIRunEventType.js +18 -0
  172. package/build/dist/Types/AI/AIRunEventType.js.map +1 -0
  173. package/build/dist/Types/AI/AIRunStatus.js +26 -0
  174. package/build/dist/Types/AI/AIRunStatus.js.map +1 -0
  175. package/build/dist/Types/AI/AIRunType.js +7 -0
  176. package/build/dist/Types/AI/AIRunType.js.map +1 -0
  177. package/build/dist/Types/LLM/LlmType.js +6 -0
  178. package/build/dist/Types/LLM/LlmType.js.map +1 -1
  179. package/build/dist/UI/Components/Header/HeaderIconDropdownButton.js +10 -5
  180. package/build/dist/UI/Components/Header/HeaderIconDropdownButton.js.map +1 -1
  181. package/build/dist/UI/Components/Markdown.tsx/MarkdownViewer.js +18 -4
  182. package/build/dist/UI/Components/Markdown.tsx/MarkdownViewer.js.map +1 -1
  183. package/build/dist/UI/Components/Page/Page.js +3 -1
  184. package/build/dist/UI/Components/Page/Page.js.map +1 -1
  185. package/build/dist/UI/Utils/LlmTypeDropdownOptions.js +38 -0
  186. package/build/dist/UI/Utils/LlmTypeDropdownOptions.js.map +1 -0
  187. package/build/dist/UI/Utils/TestLLMProvider.js +37 -0
  188. package/build/dist/UI/Utils/TestLLMProvider.js.map +1 -0
  189. package/package.json +1 -1
@@ -9,6 +9,8 @@ import LLMService, {
9
9
  LLMProviderConfig,
10
10
  LLMCompletionResponse,
11
11
  LLMMessage,
12
+ LLMToolCall,
13
+ LLMToolDefinition,
12
14
  } from "../Utils/LLM/LLMService";
13
15
  import LlmProvider from "../../Models/DatabaseModels/LlmProvider";
14
16
  import LlmLog from "../../Models/DatabaseModels/LlmLog";
@@ -20,18 +22,32 @@ import logger, { LogAttributes } from "../Utils/Logger";
20
22
 
21
23
  export interface AILogRequest {
22
24
  projectId: ObjectID;
23
- userId?: ObjectID;
25
+ userId?: ObjectID | undefined;
24
26
  feature: string; // e.g., "IncidentPostmortem", "IncidentNote"
25
27
  incidentId?: ObjectID;
26
28
  alertId?: ObjectID;
27
29
  scheduledMaintenanceId?: ObjectID;
30
+ aiRunId?: ObjectID;
31
+ /*
32
+ * When set, use this specific provider (validated against the project) rather
33
+ * than the project default. Powers the in-chat provider/model switcher.
34
+ */
35
+ llmProviderId?: ObjectID | undefined;
28
36
  messages: Array<LLMMessage>;
29
- maxTokens?: number;
30
- temperature?: number;
37
+ tools?: Array<LLMToolDefinition> | undefined;
38
+ maxTokens?: number | undefined;
39
+ temperature?: number | undefined;
40
+ /*
41
+ * When false, prompt/response previews are NOT persisted to LlmLog.
42
+ * Use for features whose content is private to a single user (e.g. AI
43
+ * chat) — LlmLog is readable by all project members.
44
+ */
45
+ storeContentPreviews?: boolean | undefined;
31
46
  }
32
47
 
33
48
  export interface AILogResponse {
34
49
  content: string;
50
+ toolCalls?: Array<LLMToolCall> | undefined;
35
51
  llmLog: LlmLog;
36
52
  }
37
53
 
@@ -46,9 +62,12 @@ export class Service extends BaseService {
46
62
  ): Promise<AILogResponse> {
47
63
  const startTime: Date = new Date();
48
64
 
49
- // Get LLM provider for the project
65
+ // Get LLM provider for the project (honoring an explicit per-chat choice).
50
66
  const llmProvider: LlmProvider | null =
51
- await LlmProviderService.getLLMProviderForProject(request.projectId);
67
+ await LlmProviderService.getProviderForChat({
68
+ projectId: request.projectId,
69
+ llmProviderId: request.llmProviderId,
70
+ });
52
71
 
53
72
  if (!llmProvider) {
54
73
  throw new BadDataException(
@@ -67,12 +86,18 @@ export class Service extends BaseService {
67
86
  logEntry.projectId = request.projectId;
68
87
  logEntry.isGlobalProvider = llmProvider.isGlobalLlm || false;
69
88
  logEntry.feature = request.feature;
70
- logEntry.requestPrompt = request.messages
71
- .map((m: LLMMessage) => {
72
- return m.content;
73
- })
74
- .join("\n")
75
- .substring(0, 5000); // Store first 5000 chars
89
+
90
+ const storeContentPreviews: boolean =
91
+ request.storeContentPreviews !== false;
92
+
93
+ logEntry.requestPrompt = storeContentPreviews
94
+ ? request.messages
95
+ .map((m: LLMMessage) => {
96
+ return m.content;
97
+ })
98
+ .join("\n")
99
+ .substring(0, 5000) // Store first 5000 chars
100
+ : "[Redacted — this content is private to the requesting user]";
76
101
  logEntry.requestStartedAt = startTime;
77
102
 
78
103
  // Set optional fields only if they have values
@@ -100,6 +125,9 @@ export class Service extends BaseService {
100
125
  if (request.scheduledMaintenanceId) {
101
126
  logEntry.scheduledMaintenanceId = request.scheduledMaintenanceId;
102
127
  }
128
+ if (request.aiRunId) {
129
+ logEntry.aiRunId = request.aiRunId;
130
+ }
103
131
 
104
132
  // Check if billing should apply
105
133
  const shouldBill: boolean =
@@ -153,6 +181,8 @@ export class Service extends BaseService {
153
181
  llmProviderConfig: llmConfig,
154
182
  messages: request.messages,
155
183
  temperature: request.temperature ?? 0.7,
184
+ maxTokens: request.maxTokens,
185
+ tools: request.tools,
156
186
  });
157
187
 
158
188
  const endTime: Date = new Date();
@@ -160,7 +190,9 @@ export class Service extends BaseService {
160
190
  // Update log with success info
161
191
  logEntry.status = LlmLogStatus.Success;
162
192
  logEntry.totalTokens = response.usage?.totalTokens || 0;
163
- logEntry.responsePreview = response.content.substring(0, 2000); // Store first 2000 chars
193
+ logEntry.responsePreview = storeContentPreviews
194
+ ? response.content.substring(0, 2000) // Store first 2000 chars
195
+ : "[Redacted — this content is private to the requesting user]";
164
196
  logEntry.requestCompletedAt = endTime;
165
197
  logEntry.durationMs = endTime.getTime() - startTime.getTime();
166
198
 
@@ -176,27 +208,16 @@ export class Service extends BaseService {
176
208
 
177
209
  // Deduct from project balance
178
210
  if (totalCost > 0) {
179
- const project: Project | null = await ProjectService.findOneById({
180
- id: request.projectId,
181
- select: { aiCurrentBalanceInUSDCents: true },
182
- props: { isRoot: true },
211
+ /*
212
+ * Atomic decrement — concurrent LLM calls within and across chat
213
+ * turns must not lose each other's deductions (a read-modify-write
214
+ * here silently forgave overlapping spend).
215
+ */
216
+ await ProjectService.deductAiBalanceInUSDCents({
217
+ projectId: request.projectId,
218
+ amountInUSDCents: totalCost,
183
219
  });
184
220
 
185
- if (project) {
186
- const newBalance: number = Math.max(
187
- 0,
188
- (project.aiCurrentBalanceInUSDCents || 0) - totalCost,
189
- );
190
-
191
- await ProjectService.updateOneById({
192
- id: request.projectId,
193
- data: {
194
- aiCurrentBalanceInUSDCents: newBalance,
195
- },
196
- props: { isRoot: true },
197
- });
198
- }
199
-
200
221
  // Check if auto-recharge is needed (do this async, don't wait)
201
222
  AIBillingService.rechargeIfBalanceIsLow(request.projectId).catch(
202
223
  (err: Error) => {
@@ -221,6 +242,7 @@ export class Service extends BaseService {
221
242
 
222
243
  return {
223
244
  content: response.content,
245
+ toolCalls: response.toolCalls,
224
246
  llmLog: savedLog,
225
247
  };
226
248
  } catch (error) {
@@ -2106,6 +2106,25 @@ class DatabaseService<TBaseModel extends BaseModel> extends BaseService {
2106
2106
  );
2107
2107
  }
2108
2108
 
2109
+ /*
2110
+ * Atomically subtract `value` from a numeric column in a single UPDATE
2111
+ * (SET col = col - value) so concurrent callers never lose each other's
2112
+ * writes the way a read-modify-write would. The column can go negative;
2113
+ * callers that gate on a non-negative balance simply reject the next
2114
+ * request rather than silently forgiving the overage.
2115
+ */
2116
+ protected async atomicDecrementColumnValueBy(data: {
2117
+ id: ObjectID;
2118
+ columnName: keyof TBaseModel;
2119
+ value: number;
2120
+ }): Promise<void> {
2121
+ await this.getRepository().decrement(
2122
+ { _id: data.id.toString() } as any,
2123
+ data.columnName as string,
2124
+ data.value,
2125
+ );
2126
+ }
2127
+
2109
2128
  @CaptureSpan()
2110
2129
  public async searchBy({
2111
2130
  skip,
@@ -6,6 +6,7 @@ import ObjectID from "../../Types/ObjectID";
6
6
  import UpdateBy from "../Types/Database/UpdateBy";
7
7
  import QueryHelper from "../Types/Database/QueryHelper";
8
8
  import LIMIT_MAX from "../../Types/Database/LimitMax";
9
+ import SortOrder from "../../Types/BaseDatabase/SortOrder";
9
10
  import CaptureSpan from "../Utils/Telemetry/CaptureSpan";
10
11
 
11
12
  export class Service extends DatabaseService<Model> {
@@ -153,6 +154,115 @@ export class Service extends DatabaseService<Model> {
153
154
 
154
155
  return null;
155
156
  }
157
+
158
+ /*
159
+ * Resolve the provider to use for a chat turn. When the user has explicitly
160
+ * chosen a provider (llmProviderId), use it — but only if it is actually
161
+ * usable by this project: either a global provider, or one owned by the
162
+ * project. Otherwise fall back to the project default / global provider.
163
+ * A stale id (e.g. the chosen provider was deleted) also falls back, so a
164
+ * conversation never breaks because its provider went away.
165
+ */
166
+ @CaptureSpan()
167
+ public async getProviderForChat(data: {
168
+ projectId: ObjectID;
169
+ llmProviderId?: ObjectID | undefined;
170
+ }): Promise<Model | null> {
171
+ if (data.llmProviderId) {
172
+ const provider: Model | null = await this.findOneBy({
173
+ query: {
174
+ _id: data.llmProviderId.toString(),
175
+ },
176
+ select: {
177
+ _id: true,
178
+ name: true,
179
+ llmType: true,
180
+ apiKey: true,
181
+ baseUrl: true,
182
+ modelName: true,
183
+ isGlobalLlm: true,
184
+ projectId: true,
185
+ costPerMillionTokensInUSDCents: true,
186
+ },
187
+ props: {
188
+ isRoot: true,
189
+ },
190
+ });
191
+
192
+ if (provider) {
193
+ const isGlobal: boolean = provider.isGlobalLlm === true;
194
+ const belongsToProject: boolean =
195
+ provider.projectId?.toString() === data.projectId.toString();
196
+
197
+ if (isGlobal || belongsToProject) {
198
+ return provider;
199
+ }
200
+ }
201
+ // Fall through to default resolution when the id is invalid/inaccessible.
202
+ }
203
+
204
+ return this.getLLMProviderForProject(data.projectId);
205
+ }
206
+
207
+ /*
208
+ * The providers a project member can pick from in the chat provider switcher:
209
+ * every provider configured for the project plus every global provider.
210
+ * Secrets (apiKey) are never selected here — this only feeds the picker UI.
211
+ */
212
+ @CaptureSpan()
213
+ public async getSelectableProvidersForProject(
214
+ projectId: ObjectID,
215
+ ): Promise<Array<Model>> {
216
+ const [projectProviders, globalProviders]: [Array<Model>, Array<Model>] =
217
+ await Promise.all([
218
+ this.findBy({
219
+ query: {
220
+ projectId: projectId,
221
+ },
222
+ select: {
223
+ _id: true,
224
+ name: true,
225
+ llmType: true,
226
+ modelName: true,
227
+ isDefault: true,
228
+ isGlobalLlm: true,
229
+ },
230
+ sort: {
231
+ isDefault: SortOrder.Descending,
232
+ name: SortOrder.Ascending,
233
+ },
234
+ skip: 0,
235
+ limit: LIMIT_MAX,
236
+ props: {
237
+ isRoot: true,
238
+ },
239
+ }),
240
+ this.findBy({
241
+ query: {
242
+ projectId: QueryHelper.isNull(),
243
+ isGlobalLlm: true,
244
+ },
245
+ select: {
246
+ _id: true,
247
+ name: true,
248
+ llmType: true,
249
+ modelName: true,
250
+ isDefault: true,
251
+ isGlobalLlm: true,
252
+ },
253
+ sort: {
254
+ name: SortOrder.Ascending,
255
+ },
256
+ skip: 0,
257
+ limit: LIMIT_MAX,
258
+ props: {
259
+ isRoot: true,
260
+ },
261
+ }),
262
+ ]);
263
+
264
+ return [...projectProviders, ...globalProviders];
265
+ }
156
266
  }
157
267
 
158
268
  export default new Service();
@@ -1915,6 +1915,30 @@ export class ProjectService extends DatabaseService<Model> {
1915
1915
  );
1916
1916
  }
1917
1917
 
1918
+ @CaptureSpan()
1919
+ /*
1920
+ * Atomically deduct AI spend from the project's balance. A single
1921
+ * SET aiCurrentBalanceInUSDCents = aiCurrentBalanceInUSDCents - amount
1922
+ * so concurrent AI runs (many LLM calls per turn, several turns per
1923
+ * project) can't clobber each other's deductions the way a
1924
+ * read-then-write would. No-op for non-positive amounts.
1925
+ */
1926
+ @CaptureSpan()
1927
+ public async deductAiBalanceInUSDCents(data: {
1928
+ projectId: ObjectID;
1929
+ amountInUSDCents: number;
1930
+ }): Promise<void> {
1931
+ if (!data.amountInUSDCents || data.amountInUSDCents <= 0) {
1932
+ return;
1933
+ }
1934
+
1935
+ await this.atomicDecrementColumnValueBy({
1936
+ id: data.projectId,
1937
+ columnName: "aiCurrentBalanceInUSDCents",
1938
+ value: data.amountInUSDCents,
1939
+ });
1940
+ }
1941
+
1918
1942
  @CaptureSpan()
1919
1943
  public async incrementAndGetIncidentCounter(
1920
1944
  projectId: ObjectID,
@@ -703,18 +703,31 @@ export default class ModelPermission {
703
703
  * scale to telemetry volume; one Postgres roundtrip + one indexed
704
704
  * predicate does. See Internal/Docs/PermissionsSimplification.md.
705
705
  */
706
- private static async addOwnedScopeToQuery<TBaseModel extends BaseModel>(
706
+ /*
707
+ * Resolves the owned-scope decision for an analytics model without touching
708
+ * a query. Returns `null` when no ownership filter applies (root/master,
709
+ * creates, models without @OwnedThrough, no applicable permission rows, or
710
+ * an unrestricted grant) — i.e. the caller has full access. Otherwise
711
+ * returns the FK column plus the exact set of parent resource IDs the user
712
+ * may read (possibly empty, meaning "access to nothing").
713
+ *
714
+ * This is the single source of truth for owned-scope resolution:
715
+ * addOwnedScopeToQuery injects it into a ClickHouse query, and
716
+ * getAccessibleServiceIdsForAnalyticsModel exposes it to callers that build
717
+ * their own aggregation SQL (e.g. the AI toolbox), so the two can never
718
+ * drift apart.
719
+ */
720
+ private static async resolveOwnedScope<TBaseModel extends BaseModel>(
707
721
  modelType: { new (): TBaseModel },
708
- query: Query<TBaseModel>,
709
722
  props: DatabaseCommonInteractionProps,
710
723
  type: DatabaseRequestType,
711
- ): Promise<Query<TBaseModel>> {
724
+ ): Promise<{ fkColumn: string; allowedResourceIds: Set<string> } | null> {
712
725
  if (props.isRoot || props.isMasterAdmin) {
713
- return query;
726
+ return null;
714
727
  }
715
728
 
716
729
  if (type === DatabaseRequestType.Create) {
717
- return query;
730
+ return null;
718
731
  }
719
732
 
720
733
  /*
@@ -725,7 +738,7 @@ export default class ModelPermission {
725
738
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
726
739
  const model: any = new modelType();
727
740
  if (!model.ownedThrough) {
728
- return query;
741
+ return null;
729
742
  }
730
743
 
731
744
  const modelPermissions: Array<Permission> = this.getModelPermissions(
@@ -748,7 +761,7 @@ export default class ModelPermission {
748
761
  );
749
762
 
750
763
  if (applicableRows.length === 0) {
751
- return query;
764
+ return null;
752
765
  }
753
766
 
754
767
  /*
@@ -771,7 +784,7 @@ export default class ModelPermission {
771
784
  },
772
785
  );
773
786
  if (hasUnrestrictedGrant) {
774
- return query;
787
+ return null;
775
788
  }
776
789
 
777
790
  const allowedResourceIds: Set<string> = new Set<string>();
@@ -831,7 +844,59 @@ export default class ModelPermission {
831
844
  allowedResourceIds.add(props.tenantId.toString());
832
845
  }
833
846
 
834
- const fkColumn: string = model.ownedThrough.fkColumn;
847
+ return { fkColumn: model.ownedThrough.fkColumn, allowedResourceIds };
848
+ }
849
+
850
+ /*
851
+ * The set of parent resource (Service) IDs the user may read for an analytics
852
+ * model, for callers that bypass findBy() and build aggregation SQL directly
853
+ * (e.g. the AI toolbox's log_histogram / query_traces, which call the
854
+ * aggregation services with a raw projectId). Pass the result as the
855
+ * aggregation's `serviceIds` filter.
856
+ *
857
+ * `null` means no ownership restriction applies — do NOT constrain (the user
858
+ * has project-wide access). A returned array (even empty) means the user is
859
+ * label/owned-restricted: constrain to exactly these IDs, and treat an empty
860
+ * array as "no accessible services" (match nothing), NEVER as "no filter".
861
+ */
862
+ public static async getAccessibleServiceIdsForAnalyticsModel<
863
+ TBaseModel extends BaseModel,
864
+ >(
865
+ modelType: { new (): TBaseModel },
866
+ props: DatabaseCommonInteractionProps,
867
+ type: DatabaseRequestType,
868
+ ): Promise<Array<ObjectID> | null> {
869
+ const scope: {
870
+ fkColumn: string;
871
+ allowedResourceIds: Set<string>;
872
+ } | null = await this.resolveOwnedScope(modelType, props, type);
873
+
874
+ if (!scope) {
875
+ return null;
876
+ }
877
+
878
+ return Array.from(scope.allowedResourceIds).map((id: string) => {
879
+ return new ObjectID(id);
880
+ });
881
+ }
882
+
883
+ private static async addOwnedScopeToQuery<TBaseModel extends BaseModel>(
884
+ modelType: { new (): TBaseModel },
885
+ query: Query<TBaseModel>,
886
+ props: DatabaseCommonInteractionProps,
887
+ type: DatabaseRequestType,
888
+ ): Promise<Query<TBaseModel>> {
889
+ const scope: {
890
+ fkColumn: string;
891
+ allowedResourceIds: Set<string>;
892
+ } | null = await this.resolveOwnedScope(modelType, props, type);
893
+
894
+ if (!scope) {
895
+ return query;
896
+ }
897
+
898
+ const fkColumn: string = scope.fkColumn;
899
+ const allowedResourceIds: Set<string> = scope.allowedResourceIds;
835
900
  const sentinelNoMatch: Array<string> = [
836
901
  ObjectID.getZeroObjectID().toString(),
837
902
  ];
@@ -0,0 +1,28 @@
1
+ import DatabaseCommonInteractionProps from "../../../Types/BaseDatabase/DatabaseCommonInteractionProps";
2
+ import NotAuthorizedException from "../../../Types/Exception/NotAuthorizedException";
3
+
4
+ /*
5
+ * AI conversations (and their messages, runs and run events) are personal to
6
+ * the user who created them. This filter pins every non-root query to the
7
+ * requesting user so one project member can never read another member's
8
+ * conversations — including through the auto-generated list/count endpoints.
9
+ */
10
+ export function pinQueryToRequestingUser<TQuery>(
11
+ query: TQuery,
12
+ props: DatabaseCommonInteractionProps,
13
+ userColumnName: string,
14
+ ): TQuery {
15
+ if (props.isRoot || props.isMasterAdmin) {
16
+ return query;
17
+ }
18
+
19
+ if (!props.userId) {
20
+ throw new NotAuthorizedException(
21
+ "AI conversations are personal and can only be accessed by the user who created them.",
22
+ );
23
+ }
24
+
25
+ (query as Record<string, unknown>)[userColumnName] = props.userId;
26
+
27
+ return query;
28
+ }