@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
@@ -1,21 +1,47 @@
1
1
  import HTTPErrorResponse from "../../../Types/API/HTTPErrorResponse";
2
2
  import HTTPResponse from "../../../Types/API/HTTPResponse";
3
3
  import URL from "../../../Types/API/URL";
4
- import { JSONObject } from "../../../Types/JSON";
4
+ import { JSONArray, JSONObject } from "../../../Types/JSON";
5
+ import JSONFunctions from "../../../Types/JSONFunctions";
5
6
  import API from "../../../Utils/API";
6
7
  import LlmType from "../../../Types/LLM/LlmType";
7
8
  import BadDataException from "../../../Types/Exception/BadDataException";
8
9
  import logger, { LogAttributes } from "../Logger";
9
10
  import CaptureSpan from "../Telemetry/CaptureSpan";
10
11
 
12
+ export interface LLMToolDefinition {
13
+ name: string;
14
+ description: string;
15
+ // JSON Schema for the tool's arguments.
16
+ inputSchema: JSONObject;
17
+ }
18
+
19
+ export interface LLMToolCall {
20
+ id: string;
21
+ name: string;
22
+ arguments: JSONObject;
23
+ /*
24
+ * Set when the provider returned malformed argument JSON that could not be
25
+ * parsed. Callers must NOT execute the tool with the empty arguments —
26
+ * surface the error to the model so it can retry.
27
+ */
28
+ argumentsParseError?: string | undefined;
29
+ }
30
+
11
31
  export interface LLMMessage {
12
- role: "system" | "user" | "assistant";
32
+ role: "system" | "user" | "assistant" | "tool";
13
33
  content: string;
34
+ // Set on assistant messages that requested tool calls.
35
+ toolCalls?: Array<LLMToolCall> | undefined;
36
+ // Set on tool messages: which tool call this result answers.
37
+ toolCallId?: string | undefined;
14
38
  }
15
39
 
16
40
  export interface LLMCompletionRequest {
17
41
  messages: Array<LLMMessage>;
18
- temperature?: number;
42
+ temperature?: number | undefined;
43
+ maxTokens?: number | undefined;
44
+ tools?: Array<LLMToolDefinition> | undefined;
19
45
  llmProviderConfig: LLMProviderConfig;
20
46
  }
21
47
 
@@ -27,6 +53,8 @@ export interface LLMUsage {
27
53
 
28
54
  export interface LLMCompletionResponse {
29
55
  content: string;
56
+ toolCalls?: Array<LLMToolCall> | undefined;
57
+ stopReason?: "stop" | "tool_use" | undefined;
30
58
  usage: LLMUsage | undefined;
31
59
  }
32
60
 
@@ -48,6 +76,7 @@ export default class LLMService {
48
76
  case LlmType.OpenAI:
49
77
  case LlmType.Groq:
50
78
  case LlmType.Mistral:
79
+ case LlmType.OpenAICompatible:
51
80
  return await this.getOpenAICompatibleCompletion(config, request);
52
81
  case LlmType.AzureOpenAI:
53
82
  return await this.getAzureOpenAICompletion(config, request);
@@ -60,15 +89,277 @@ export default class LLMService {
60
89
  }
61
90
  }
62
91
 
92
+ /*
93
+ * OpenAI-compatible wire format (OpenAI, Groq, Mistral, Azure OpenAI).
94
+ */
95
+
96
+ private static toOpenAIMessages(
97
+ messages: Array<LLMMessage>,
98
+ ): Array<JSONObject> {
99
+ return messages.map((msg: LLMMessage) => {
100
+ if (msg.role === "assistant" && msg.toolCalls && msg.toolCalls.length) {
101
+ return {
102
+ role: "assistant",
103
+ content: msg.content || null,
104
+ tool_calls: msg.toolCalls.map((toolCall: LLMToolCall) => {
105
+ return {
106
+ id: toolCall.id,
107
+ type: "function",
108
+ function: {
109
+ name: toolCall.name,
110
+ arguments: JSON.stringify(toolCall.arguments),
111
+ },
112
+ };
113
+ }),
114
+ };
115
+ }
116
+
117
+ if (msg.role === "tool") {
118
+ return {
119
+ role: "tool",
120
+ tool_call_id: msg.toolCallId || "",
121
+ content: msg.content,
122
+ };
123
+ }
124
+
125
+ return {
126
+ role: msg.role,
127
+ content: msg.content,
128
+ };
129
+ });
130
+ }
131
+
132
+ private static toOpenAITools(
133
+ tools: Array<LLMToolDefinition>,
134
+ ): Array<JSONObject> {
135
+ return tools.map((tool: LLMToolDefinition) => {
136
+ return {
137
+ type: "function",
138
+ function: {
139
+ name: tool.name,
140
+ description: tool.description,
141
+ parameters: tool.inputSchema,
142
+ },
143
+ };
144
+ });
145
+ }
146
+
147
+ private static parseOpenAIToolCalls(
148
+ message: JSONObject,
149
+ ): Array<LLMToolCall> | undefined {
150
+ const rawToolCalls: JSONArray | undefined = message["tool_calls"] as
151
+ | JSONArray
152
+ | undefined;
153
+
154
+ if (!rawToolCalls || rawToolCalls.length === 0) {
155
+ return undefined;
156
+ }
157
+
158
+ return rawToolCalls.map((rawToolCall: JSONObject, index: number) => {
159
+ const fn: JSONObject = (rawToolCall["function"] as JSONObject) || {};
160
+ const parsed: { arguments: JSONObject; error?: string | undefined } =
161
+ this.parseToolCallArguments((fn["arguments"] as string) || "{}");
162
+
163
+ return {
164
+ id: (rawToolCall["id"] as string) || `tool_call_${index}`,
165
+ name: (fn["name"] as string) || "",
166
+ arguments: parsed.arguments,
167
+ argumentsParseError: parsed.error,
168
+ };
169
+ });
170
+ }
171
+
172
+ /*
173
+ * Models sometimes emit slightly malformed argument JSON (trailing commas,
174
+ * single quotes). Try strict JSON first, then tolerant JSON5, and report a
175
+ * parse error instead of silently executing with empty arguments.
176
+ */
177
+ private static parseToolCallArguments(rawArguments: string): {
178
+ arguments: JSONObject;
179
+ error?: string | undefined;
180
+ } {
181
+ try {
182
+ return { arguments: JSON.parse(rawArguments) };
183
+ } catch {
184
+ // fall through to tolerant parsing
185
+ }
186
+
187
+ try {
188
+ const parsed: JSONObject | unknown = JSONFunctions.parse(rawArguments);
189
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
190
+ return { arguments: parsed as JSONObject };
191
+ }
192
+ return {
193
+ arguments: {},
194
+ error: "Tool arguments were not a JSON object.",
195
+ };
196
+ } catch {
197
+ return {
198
+ arguments: {},
199
+ error: "Tool arguments were malformed JSON and could not be parsed.",
200
+ };
201
+ }
202
+ }
203
+
204
+ private static buildOpenAIRequestBody(
205
+ modelName: string,
206
+ request: LLMCompletionRequest,
207
+ ): JSONObject {
208
+ const data: JSONObject = {
209
+ model: modelName,
210
+ messages: this.toOpenAIMessages(request.messages),
211
+ temperature: request.temperature ?? 0.7,
212
+ };
213
+
214
+ if (request.maxTokens) {
215
+ data["max_tokens"] = request.maxTokens;
216
+ }
217
+
218
+ if (request.tools && request.tools.length > 0) {
219
+ data["tools"] = this.toOpenAITools(request.tools);
220
+ }
221
+
222
+ return data;
223
+ }
224
+
225
+ private static parseOpenAIResponse(
226
+ jsonData: JSONObject,
227
+ providerName: string,
228
+ ): LLMCompletionResponse {
229
+ const choices: Array<JSONObject> = jsonData["choices"] as Array<JSONObject>;
230
+
231
+ if (!choices || choices.length === 0) {
232
+ throw new BadDataException(`No response from ${providerName}`);
233
+ }
234
+
235
+ const message: JSONObject = choices[0]!["message"] as JSONObject;
236
+ const usage: JSONObject = jsonData["usage"] as JSONObject;
237
+ const toolCalls: Array<LLMToolCall> | undefined =
238
+ this.parseOpenAIToolCalls(message);
239
+
240
+ return {
241
+ content: (message["content"] as string) || "",
242
+ toolCalls: toolCalls,
243
+ stopReason: toolCalls && toolCalls.length > 0 ? "tool_use" : "stop",
244
+ usage: usage
245
+ ? {
246
+ promptTokens: usage["prompt_tokens"] as number,
247
+ completionTokens: usage["completion_tokens"] as number,
248
+ totalTokens: usage["total_tokens"] as number,
249
+ }
250
+ : undefined,
251
+ };
252
+ }
253
+
254
+ /*
255
+ * Build the chat completions URL for an OpenAI-compatible server from the
256
+ * configured base URL. Users enter the base URL in a few different ways, and
257
+ * we normalize all of them onto the right endpoint instead of 404-ing:
258
+ *
259
+ * - Already a full endpoint (".../chat/completions") -> used as-is.
260
+ * - Includes a path (".../v1", ".../openai/v1") -> append "/chat/completions".
261
+ * - Bare server root ("http://host:8000") -> append "/v1/chat/completions",
262
+ * since vLLM, LocalAI and similar servers expose the OpenAI-compatible API
263
+ * under /v1 by default. This is the most common self-hosted setup and the
264
+ * easiest one for users to get wrong (omitting /v1 returns FastAPI's
265
+ * {"detail":"Not Found"}).
266
+ *
267
+ * The endpoint segment is only ever appended to the PATH portion: any
268
+ * ?query or #fragment on the base URL is split off first and re-attached
269
+ * afterwards (otherwise "/chat/completions" would land inside the query or
270
+ * fragment). Trailing slashes are stripped so we never emit
271
+ * ".../v1//chat/completions", and the URL scheme is lower-cased because URL
272
+ * schemes are case-insensitive but downstream URL parsing only recognizes
273
+ * lowercase http/https.
274
+ */
275
+ private static buildOpenAICompatibleChatCompletionsUrl(
276
+ baseUrl: string,
277
+ ): string {
278
+ const raw: string = baseUrl.trim();
279
+
280
+ // Split off #fragment, then ?query, so we can operate on the path alone.
281
+ const fragmentIndex: number = raw.indexOf("#");
282
+ const fragment: string =
283
+ fragmentIndex >= 0 ? raw.substring(fragmentIndex) : "";
284
+ const withoutFragment: string =
285
+ fragmentIndex >= 0 ? raw.substring(0, fragmentIndex) : raw;
286
+
287
+ const queryIndex: number = withoutFragment.indexOf("?");
288
+ const query: string =
289
+ queryIndex >= 0 ? withoutFragment.substring(queryIndex) : "";
290
+
291
+ let base: string =
292
+ queryIndex >= 0
293
+ ? withoutFragment.substring(0, queryIndex)
294
+ : withoutFragment;
295
+
296
+ /*
297
+ * Lower-case the scheme (e.g. "HTTP://" -> "http://") and strip trailing
298
+ * slashes.
299
+ */
300
+ const schemeRegex: RegExp = /^[a-z][a-z0-9+.-]*:\/\//i;
301
+ base = base
302
+ .replace(schemeRegex, (scheme: string) => {
303
+ return scheme.toLowerCase();
304
+ })
305
+ .replace(/\/+$/, "");
306
+
307
+ let path: string;
308
+ const fullEndpointRegex: RegExp = /\/chat\/completions$/i;
309
+ const hasPathAfterHostRegex: RegExp = /^[a-z][a-z0-9+.-]*:\/\/[^/]+\/.+/i;
310
+
311
+ if (fullEndpointRegex.test(base)) {
312
+ // The user already pointed us at the full endpoint.
313
+ path = base;
314
+ } else if (hasPathAfterHostRegex.test(base)) {
315
+ /*
316
+ * The base URL carries a path after the host[:port] (e.g.
317
+ * "http://host:8000/v1" or ".../openai/v1") — trust it and only append
318
+ * the endpoint.
319
+ */
320
+ path = `${base}/chat/completions`;
321
+ } else {
322
+ /*
323
+ * Bare server root ("http://host:8000") — add the conventional /v1
324
+ * prefix that OpenAI-compatible servers (vLLM, LocalAI, ...) use.
325
+ */
326
+ path = `${base}/v1/chat/completions`;
327
+ }
328
+
329
+ return `${path}${query}${fragment}`;
330
+ }
331
+
63
332
  @CaptureSpan()
64
333
  private static async getOpenAICompatibleCompletion(
65
334
  config: LLMProviderConfig,
66
335
  request: LLMCompletionRequest,
67
336
  ): Promise<LLMCompletionResponse> {
68
- if (!config.apiKey) {
337
+ /*
338
+ * Generic OpenAI-compatible servers (vLLM, LocalAI, etc.) are usually
339
+ * self-hosted, frequently keyless, and have no canonical endpoint or
340
+ * default model — so the API key is optional but the base URL and model
341
+ * name must be provided. The hosted providers (OpenAI, Groq, Mistral)
342
+ * keep requiring a key and fall back to sensible defaults.
343
+ */
344
+ const isGenericOpenAICompatible: boolean =
345
+ config.llmType === LlmType.OpenAICompatible;
346
+
347
+ if (!isGenericOpenAICompatible && !config.apiKey) {
69
348
  throw new BadDataException(`${config.llmType} API key is required`);
70
349
  }
71
350
 
351
+ if (isGenericOpenAICompatible && !config.baseUrl) {
352
+ throw new BadDataException(
353
+ "Base URL is required for OpenAI-compatible providers (e.g. http://your-vllm-server:8000/v1)",
354
+ );
355
+ }
356
+
357
+ if (isGenericOpenAICompatible && !config.modelName) {
358
+ throw new BadDataException(
359
+ "Model Name is required for OpenAI-compatible providers. It must match a model your server exposes.",
360
+ );
361
+ }
362
+
72
363
  const defaultBaseUrls: Record<string, string> = {
73
364
  [LlmType.OpenAI]: "https://api.openai.com/v1",
74
365
  [LlmType.Groq]: "https://api.groq.com/openai/v1",
@@ -89,20 +380,20 @@ export default class LLMService {
89
380
  config.modelName || defaultModels[config.llmType] || "gpt-4o";
90
381
  const response: HTTPErrorResponse | HTTPResponse<JSONObject> =
91
382
  await API.post<JSONObject>({
92
- url: URL.fromString(`${baseUrl}/chat/completions`),
93
- data: {
94
- model: modelName,
95
- messages: request.messages.map((msg: LLMMessage) => {
96
- return {
97
- role: msg.role,
98
- content: msg.content,
99
- };
100
- }),
101
- temperature: request.temperature ?? 0.7,
102
- },
383
+ url: URL.fromString(
384
+ this.buildOpenAICompatibleChatCompletionsUrl(baseUrl),
385
+ ),
386
+ data: this.buildOpenAIRequestBody(modelName, request),
103
387
  headers: {
104
- Authorization: `Bearer ${config.apiKey}`,
105
388
  "Content-Type": "application/json",
389
+ /*
390
+ * Only send Authorization when a key is configured — a keyless
391
+ * server (e.g. vLLM started without --api-key) rejects an empty
392
+ * bearer token.
393
+ */
394
+ ...(config.apiKey
395
+ ? { Authorization: `Bearer ${config.apiKey}` }
396
+ : {}),
106
397
  },
107
398
  options: {
108
399
  retries: 2,
@@ -124,26 +415,10 @@ export default class LLMService {
124
415
  );
125
416
  }
126
417
 
127
- const jsonData: JSONObject = response.jsonData as JSONObject;
128
- const choices: Array<JSONObject> = jsonData["choices"] as Array<JSONObject>;
129
-
130
- if (!choices || choices.length === 0) {
131
- throw new BadDataException(`No response from ${config.llmType}`);
132
- }
133
-
134
- const message: JSONObject = choices[0]!["message"] as JSONObject;
135
- const usage: JSONObject = jsonData["usage"] as JSONObject;
136
-
137
- return {
138
- content: message["content"] as string,
139
- usage: usage
140
- ? {
141
- promptTokens: usage["prompt_tokens"] as number,
142
- completionTokens: usage["completion_tokens"] as number,
143
- totalTokens: usage["total_tokens"] as number,
144
- }
145
- : undefined,
146
- };
418
+ return this.parseOpenAIResponse(
419
+ response.jsonData as JSONObject,
420
+ config.llmType,
421
+ );
147
422
  }
148
423
 
149
424
  /*
@@ -192,16 +467,7 @@ export default class LLMService {
192
467
  const response: HTTPErrorResponse | HTTPResponse<JSONObject> =
193
468
  await API.post<JSONObject>({
194
469
  url: URL.fromString(requestUrl),
195
- data: {
196
- model: modelName,
197
- messages: request.messages.map((msg: LLMMessage) => {
198
- return {
199
- role: msg.role,
200
- content: msg.content,
201
- };
202
- }),
203
- temperature: request.temperature ?? 0.7,
204
- },
470
+ data: this.buildOpenAIRequestBody(modelName, request),
205
471
  headers: {
206
472
  "api-key": config.apiKey,
207
473
  "Content-Type": "application/json",
@@ -226,26 +492,86 @@ export default class LLMService {
226
492
  );
227
493
  }
228
494
 
229
- const jsonData: JSONObject = response.jsonData as JSONObject;
230
- const choices: Array<JSONObject> = jsonData["choices"] as Array<JSONObject>;
495
+ return this.parseOpenAIResponse(
496
+ response.jsonData as JSONObject,
497
+ "Azure OpenAI",
498
+ );
499
+ }
231
500
 
232
- if (!choices || choices.length === 0) {
233
- throw new BadDataException("No response from Azure OpenAI");
234
- }
501
+ /*
502
+ * Anthropic wire format. System message is hoisted, tool results ride in
503
+ * user messages as tool_result blocks, and max_tokens is required by the
504
+ * API.
505
+ */
235
506
 
236
- const message: JSONObject = choices[0]!["message"] as JSONObject;
237
- const usage: JSONObject = jsonData["usage"] as JSONObject;
507
+ private static readonly ANTHROPIC_DEFAULT_MAX_TOKENS: number = 4096;
238
508
 
239
- return {
240
- content: message["content"] as string,
241
- usage: usage
242
- ? {
243
- promptTokens: usage["prompt_tokens"] as number,
244
- completionTokens: usage["completion_tokens"] as number,
245
- totalTokens: usage["total_tokens"] as number,
246
- }
247
- : undefined,
248
- };
509
+ private static toAnthropicMessages(
510
+ messages: Array<LLMMessage>,
511
+ ): Array<JSONObject> {
512
+ const anthropicMessages: Array<JSONObject> = [];
513
+
514
+ for (const msg of messages) {
515
+ if (msg.role === "system") {
516
+ continue; // hoisted separately
517
+ }
518
+
519
+ if (msg.role === "assistant" && msg.toolCalls && msg.toolCalls.length) {
520
+ const contentBlocks: Array<JSONObject> = [];
521
+
522
+ if (msg.content) {
523
+ contentBlocks.push({ type: "text", text: msg.content });
524
+ }
525
+
526
+ for (const toolCall of msg.toolCalls) {
527
+ contentBlocks.push({
528
+ type: "tool_use",
529
+ id: toolCall.id,
530
+ name: toolCall.name,
531
+ input: toolCall.arguments,
532
+ });
533
+ }
534
+
535
+ anthropicMessages.push({ role: "assistant", content: contentBlocks });
536
+ continue;
537
+ }
538
+
539
+ if (msg.role === "tool") {
540
+ const toolResultBlock: JSONObject = {
541
+ type: "tool_result",
542
+ tool_use_id: msg.toolCallId || "",
543
+ content: msg.content,
544
+ };
545
+
546
+ /*
547
+ * Tool results must be user messages. Merge consecutive tool
548
+ * results into one user message so roles keep alternating.
549
+ */
550
+ const lastMessage: JSONObject | undefined =
551
+ anthropicMessages[anthropicMessages.length - 1];
552
+
553
+ if (
554
+ lastMessage &&
555
+ lastMessage["role"] === "user" &&
556
+ Array.isArray(lastMessage["content"])
557
+ ) {
558
+ (lastMessage["content"] as Array<JSONObject>).push(toolResultBlock);
559
+ } else {
560
+ anthropicMessages.push({
561
+ role: "user",
562
+ content: [toolResultBlock],
563
+ });
564
+ }
565
+ continue;
566
+ }
567
+
568
+ anthropicMessages.push({
569
+ role: msg.role,
570
+ content: msg.content,
571
+ });
572
+ }
573
+
574
+ return anthropicMessages;
249
575
  }
250
576
 
251
577
  @CaptureSpan()
@@ -260,31 +586,36 @@ export default class LLMService {
260
586
  const baseUrl: string = config.baseUrl || "https://api.anthropic.com/v1";
261
587
  const modelName: string = config.modelName || "claude-sonnet-4-20250514";
262
588
 
263
- // Anthropic requires system message to be separate
264
589
  let systemMessage: string = "";
265
- const userMessages: Array<{ role: string; content: string }> = [];
266
590
 
267
591
  for (const msg of request.messages) {
268
592
  if (msg.role === "system") {
269
593
  systemMessage = msg.content;
270
- } else {
271
- userMessages.push({
272
- role: msg.role,
273
- content: msg.content,
274
- });
275
594
  }
276
595
  }
277
596
 
278
597
  const requestData: JSONObject = {
279
598
  model: modelName,
280
- messages: userMessages,
599
+ messages: this.toAnthropicMessages(request.messages),
281
600
  temperature: request.temperature ?? 0.7,
601
+ // Anthropic requires max_tokens on every request.
602
+ max_tokens: request.maxTokens || LLMService.ANTHROPIC_DEFAULT_MAX_TOKENS,
282
603
  };
283
604
 
284
605
  if (systemMessage) {
285
606
  requestData["system"] = systemMessage;
286
607
  }
287
608
 
609
+ if (request.tools && request.tools.length > 0) {
610
+ requestData["tools"] = request.tools.map((tool: LLMToolDefinition) => {
611
+ return {
612
+ name: tool.name,
613
+ description: tool.description,
614
+ input_schema: tool.inputSchema,
615
+ };
616
+ });
617
+ }
618
+
288
619
  const response: HTTPErrorResponse | HTTPResponse<JSONObject> =
289
620
  await API.post<JSONObject>({
290
621
  url: URL.fromString(`${baseUrl}/messages`),
@@ -321,20 +652,37 @@ export default class LLMService {
321
652
  throw new BadDataException("No response from Anthropic");
322
653
  }
323
654
 
324
- const textContent: JSONObject | undefined = content.find(
325
- (c: JSONObject) => {
326
- return c["type"] === "text";
327
- },
328
- );
655
+ const textContent: string = content
656
+ .filter((block: JSONObject) => {
657
+ return block["type"] === "text";
658
+ })
659
+ .map((block: JSONObject) => {
660
+ return block["text"] as string;
661
+ })
662
+ .join("");
663
+
664
+ const toolCalls: Array<LLMToolCall> = content
665
+ .filter((block: JSONObject) => {
666
+ return block["type"] === "tool_use";
667
+ })
668
+ .map((block: JSONObject) => {
669
+ return {
670
+ id: (block["id"] as string) || "",
671
+ name: (block["name"] as string) || "",
672
+ arguments: (block["input"] as JSONObject) || {},
673
+ };
674
+ });
329
675
 
330
- if (!textContent) {
676
+ if (!textContent && toolCalls.length === 0) {
331
677
  throw new BadDataException("No text content in Anthropic response");
332
678
  }
333
679
 
334
680
  const usage: JSONObject = jsonData["usage"] as JSONObject;
335
681
 
336
682
  return {
337
- content: textContent["text"] as string,
683
+ content: textContent,
684
+ toolCalls: toolCalls.length > 0 ? toolCalls : undefined,
685
+ stopReason: jsonData["stop_reason"] === "tool_use" ? "tool_use" : "stop",
338
686
  usage: usage
339
687
  ? {
340
688
  promptTokens: usage["input_tokens"] as number,
@@ -347,6 +695,11 @@ export default class LLMService {
347
695
  };
348
696
  }
349
697
 
698
+ /*
699
+ * Ollama native /api/chat. Deliberately NOT routed through the
700
+ * OpenAI-compatible branch: Ollama deployments are keyless by design and
701
+ * the OpenAI branch requires an API key.
702
+ */
350
703
  @CaptureSpan()
351
704
  private static async getOllamaCompletion(
352
705
  config: LLMProviderConfig,
@@ -358,22 +711,44 @@ export default class LLMService {
358
711
 
359
712
  const modelName: string = config.modelName || "llama2";
360
713
 
714
+ const requestData: JSONObject = {
715
+ model: modelName,
716
+ messages: request.messages.map((msg: LLMMessage) => {
717
+ if (msg.role === "assistant" && msg.toolCalls && msg.toolCalls.length) {
718
+ return {
719
+ role: "assistant",
720
+ content: msg.content || "",
721
+ tool_calls: msg.toolCalls.map((toolCall: LLMToolCall) => {
722
+ return {
723
+ function: {
724
+ name: toolCall.name,
725
+ arguments: toolCall.arguments,
726
+ },
727
+ };
728
+ }),
729
+ };
730
+ }
731
+
732
+ return {
733
+ role: msg.role,
734
+ content: msg.content,
735
+ };
736
+ }),
737
+ stream: false,
738
+ options: {
739
+ temperature: request.temperature ?? 0.7,
740
+ ...(request.maxTokens ? { num_predict: request.maxTokens } : {}),
741
+ },
742
+ };
743
+
744
+ if (request.tools && request.tools.length > 0) {
745
+ requestData["tools"] = this.toOpenAITools(request.tools);
746
+ }
747
+
361
748
  const response: HTTPErrorResponse | HTTPResponse<JSONObject> =
362
749
  await API.post<JSONObject>({
363
750
  url: URL.fromString(`${config.baseUrl}/api/chat`),
364
- data: {
365
- model: modelName,
366
- messages: request.messages.map((msg: LLMMessage) => {
367
- return {
368
- role: msg.role,
369
- content: msg.content,
370
- };
371
- }),
372
- stream: false,
373
- options: {
374
- temperature: request.temperature ?? 0.7,
375
- },
376
- },
751
+ data: requestData,
377
752
  headers: {
378
753
  "Content-Type": "application/json",
379
754
  },
@@ -404,8 +779,43 @@ export default class LLMService {
404
779
  throw new BadDataException("No response from Ollama");
405
780
  }
406
781
 
782
+ const rawToolCalls: JSONArray | undefined = message["tool_calls"] as
783
+ | JSONArray
784
+ | undefined;
785
+
786
+ let toolCalls: Array<LLMToolCall> | undefined = undefined;
787
+
788
+ if (rawToolCalls && rawToolCalls.length > 0) {
789
+ toolCalls = rawToolCalls.map((rawToolCall: JSONObject, index: number) => {
790
+ const fn: JSONObject = (rawToolCall["function"] as JSONObject) || {};
791
+
792
+ let parsedArguments: JSONObject = {};
793
+ let parseError: string | undefined = undefined;
794
+ const rawArguments: unknown = fn["arguments"];
795
+
796
+ if (typeof rawArguments === "string") {
797
+ const parsed: { arguments: JSONObject; error?: string | undefined } =
798
+ this.parseToolCallArguments(rawArguments);
799
+ parsedArguments = parsed.arguments;
800
+ parseError = parsed.error;
801
+ } else if (rawArguments && typeof rawArguments === "object") {
802
+ parsedArguments = rawArguments as JSONObject;
803
+ }
804
+
805
+ // Ollama does not return tool-call ids; synthesize stable ones.
806
+ return {
807
+ id: `tool_call_${index}`,
808
+ name: (fn["name"] as string) || "",
809
+ arguments: parsedArguments,
810
+ argumentsParseError: parseError,
811
+ };
812
+ });
813
+ }
814
+
407
815
  return {
408
- content: message["content"] as string,
816
+ content: (message["content"] as string) || "",
817
+ toolCalls: toolCalls,
818
+ stopReason: toolCalls && toolCalls.length > 0 ? "tool_use" : "stop",
409
819
  usage: undefined, // Ollama doesn't provide token usage in the same way
410
820
  };
411
821
  }