@devopsplaybook.io/common-utils 1.4.0 → 1.5.0-beta.14.dd2303d

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.
package/AGENTS.md CHANGED
@@ -29,6 +29,7 @@ src/
29
29
  UsersRoutes.ts # Standard fastify user management routes
30
30
  SystemCommand.ts # Promise wrapper around child_process.exec
31
31
  Timeout.ts # Promise wrapper around setTimeout
32
+ LLM.ts # OpenAI-compatible chat completions client (LLMClient)
32
33
  *.spec.ts # Co-located test files
33
34
  .github/workflows/
34
35
  main-build.yml # Caller: push to main -> reusable-npm-merge
package/README.md CHANGED
@@ -33,7 +33,7 @@ npm install @devopsplaybook.io/common-utils
33
33
  | `pg` | PostgreSQL client (`Pool`) |
34
34
  | `fs-extra` | File system helpers |
35
35
  | `uuid` | UUID generation for JWT keys |
36
- | `axios` | HTTP client for the notifications integration |
36
+ | `axios` | HTTP client for the notifications and LLM integrations |
37
37
  | `bcrypt` | Password hashing for the users module |
38
38
  | `jsonwebtoken` | JWT signing/verification for the auth module |
39
39
  | `fastify` | HTTP framework types for the users routes |
@@ -336,6 +336,48 @@ await client.send({
336
336
 
337
337
  ---
338
338
 
339
+ #### `LLM` -- OpenAI-Compatible Chat Completions Client
340
+
341
+ Client for any OpenAI-compatible chat completions API (DeepSeek, Moonshot, Ollama, etc.), centralizing the `LLM_API_KEY` / `LLM_API_URL` / `LLM_MODEL` integration used across server projects. The client follows the same fail-safe pattern as the notifications client: it is disabled (and logs once at construction) when `apiKey`, `apiUrl` or `model` is missing. Unlike notifications, `request` on a disabled client throws, since a silently empty LLM result is rarely what the caller wants — check `isEnabled()` first.
342
+
343
+ ```ts
344
+ import { LLMClient } from "@devopsplaybook.io/common-utils";
345
+
346
+ const llm = new LLMClient({
347
+ apiKey: config.LLM_API_KEY,
348
+ apiUrl: config.LLM_API_URL,
349
+ model: config.LLM_MODEL,
350
+ logger: OTelLogger().createModuleLogger("llm"),
351
+ });
352
+
353
+ if (llm.isEnabled()) {
354
+ const response = await llm.request([
355
+ { role: "system", content: "You summarize text." },
356
+ { role: "user", content: someText },
357
+ ]);
358
+ console.log(response.content, response.totalTokens);
359
+
360
+ // JSON output mode (response_format: json_object) and per-call model override
361
+ const json = await llm.request(
362
+ [{ role: "user", content: "Reply with a JSON object" }],
363
+ { jsonMode: true, model: "other-model" },
364
+ );
365
+ }
366
+ ```
367
+
368
+ | Export | Description |
369
+ | -------------------- | -------------------------------------------------------------------------- |
370
+ | `LLMClient` | HTTP client with `isEnabled()` and `request(messages, options?)` |
371
+ | `LLMClientConfig` | `{ apiKey, apiUrl, model, timeoutMs?, logger? }` constructor configuration |
372
+ | `LLMMessage` | `{ role, content }` chat message |
373
+ | `LLMRequestOptions` | `{ jsonMode?, model? }` per-request overrides |
374
+ | `LLMResponse` | `{ content, totalTokens }` normalized response |
375
+ | `LLMLogger` | Minimal logger interface (`info`/`error`), console by default |
376
+
377
+ **Behaviour details**: requests are sent with `stream: false` and Bearer authentication; the default timeout is 120 seconds (`timeoutMs` overrides it). Provider errors are rethrown as `Error` carrying the provider message when available (e.g., `error.message` from the HTTP response). An empty content is returned as-is — applications that need an empty-content policy (e.g., retry on reasoning models) keep it in their own code.
378
+
379
+ ---
380
+
339
381
  #### `Auth`, `User`, `UserSession`, `UserPassword`, `UsersData`, `UsersRoutes` -- Authentication and User Management
340
382
 
341
383
  Standard JWT-based authentication and user management shared across all server projects: JWT key persistence in the `metadata` table, request authentication helpers, bcrypt password hashing, users CRUD, and ready-to-register fastify routes (login, user CRUD, password change).
package/dist/index.d.ts CHANGED
@@ -5,6 +5,7 @@ export * from "./src/DbUtilsNoTelemetry";
5
5
  export * from "./src/SqlDbUtils";
6
6
  export * from "./src/PostgresDbUtils";
7
7
  export * from "./src/Notifications";
8
+ export * from "./src/LLM";
8
9
  export * from "./src/SystemCommand";
9
10
  export * from "./src/Timeout";
10
11
  export * from "./src/users/User";
package/dist/index.js CHANGED
@@ -21,6 +21,7 @@ __exportStar(require("./src/DbUtilsNoTelemetry"), exports);
21
21
  __exportStar(require("./src/SqlDbUtils"), exports);
22
22
  __exportStar(require("./src/PostgresDbUtils"), exports);
23
23
  __exportStar(require("./src/Notifications"), exports);
24
+ __exportStar(require("./src/LLM"), exports);
24
25
  __exportStar(require("./src/SystemCommand"), exports);
25
26
  __exportStar(require("./src/Timeout"), exports);
26
27
  __exportStar(require("./src/users/User"), exports);
@@ -0,0 +1,110 @@
1
+ /**
2
+ * A single chat message in OpenAI-compatible format.
3
+ */
4
+ export interface LLMMessage {
5
+ /** Message role, e.g. "system", "user", "assistant" */
6
+ role: string;
7
+ /** Message content */
8
+ content: string;
9
+ }
10
+ /**
11
+ * Minimal logger interface expected by the LLM client.
12
+ * Matches the subset of the OTel logger used by devopsplaybook.io projects.
13
+ */
14
+ export interface LLMLogger {
15
+ info(message: string): void;
16
+ error(message: string, err?: unknown): void;
17
+ }
18
+ /**
19
+ * Configuration for {@link LLMClient}.
20
+ */
21
+ export interface LLMClientConfig {
22
+ /** API key used for Bearer authentication */
23
+ apiKey: string;
24
+ /** Chat completions endpoint URL (e.g., "https://api.deepseek.com/chat/completions") */
25
+ apiUrl: string;
26
+ /** Model name to use (e.g., "deepseek-chat") */
27
+ model: string;
28
+ /** Request timeout in milliseconds (defaults to 120000) */
29
+ timeoutMs?: number;
30
+ /** Optional logger; falls back to console when omitted */
31
+ logger?: LLMLogger;
32
+ }
33
+ /**
34
+ * Per-request options for {@link LLMClient.request}.
35
+ */
36
+ export interface LLMRequestOptions {
37
+ /** Request JSON output (`response_format: json_object`). Defaults to false. */
38
+ jsonMode?: boolean;
39
+ /** Override the configured model for this call */
40
+ model?: string;
41
+ }
42
+ /**
43
+ * Normalized response from a chat completion call.
44
+ */
45
+ export interface LLMResponse {
46
+ /** Content of the first choice (empty string when the model returned none) */
47
+ content: string;
48
+ /** Total tokens used, as reported by the provider (0 when unavailable) */
49
+ totalTokens: number;
50
+ }
51
+ /**
52
+ * Client for OpenAI-compatible chat completions APIs (DeepSeek, Moonshot,
53
+ * Ollama, etc.).
54
+ *
55
+ * The client follows the same fail-safe pattern as {@link NotificationsClient}:
56
+ *
57
+ * - It is disabled when `apiKey`, `apiUrl` or `model` is missing, so a
58
+ * partially configured parent application still starts.
59
+ * - The integration status is logged exactly once, at construction time.
60
+ * - Calling `request` on a disabled client throws, since an LLM call that
61
+ * silently returns nothing is rarely what the caller wants. Check
62
+ * `isEnabled()` before relying on the client.
63
+ * - Provider errors are rethrown as `Error` with the provider message when
64
+ * available, so callers get actionable failure reasons.
65
+ *
66
+ * @example
67
+ * ```ts
68
+ * const llm = new LLMClient({
69
+ * apiKey: config.LLM_API_KEY,
70
+ * apiUrl: config.LLM_API_URL,
71
+ * model: config.LLM_MODEL,
72
+ * logger: OTelLogger().createModuleLogger("llm"),
73
+ * });
74
+ *
75
+ * if (llm.isEnabled()) {
76
+ * const response = await llm.request([
77
+ * { role: "system", content: "You summarize text." },
78
+ * { role: "user", content: someText },
79
+ * ]);
80
+ * console.log(response.content, response.totalTokens);
81
+ * }
82
+ * ```
83
+ */
84
+ export declare class LLMClient {
85
+ private client;
86
+ private readonly enabled;
87
+ private readonly model;
88
+ private readonly timeoutMs;
89
+ private readonly logger;
90
+ constructor(config: LLMClientConfig);
91
+ /**
92
+ * Check whether the client is properly configured.
93
+ */
94
+ isEnabled(): boolean;
95
+ /**
96
+ * Send a chat completion request.
97
+ *
98
+ * @param messages The chat messages to send.
99
+ * @param options Optional per-request overrides (JSON mode, model).
100
+ * @returns The first choice content and total token usage.
101
+ * @throws When the client is disabled, the provider returns an error, or
102
+ * the response has no choices.
103
+ */
104
+ request(messages: LLMMessage[], options?: LLMRequestOptions): Promise<LLMResponse>;
105
+ /**
106
+ * Extract a readable error message, preferring the provider's error
107
+ * payload when the failure comes from an HTTP response.
108
+ */
109
+ private extractErrorMessage;
110
+ }
@@ -0,0 +1,131 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.LLMClient = void 0;
7
+ const axios_1 = __importDefault(require("axios"));
8
+ /** Console fallback used when no logger is injected. */
9
+ const consoleLogger = {
10
+ info: (message) => console.log(message),
11
+ error: (message, err) => console.error(message, err),
12
+ };
13
+ /**
14
+ * Client for OpenAI-compatible chat completions APIs (DeepSeek, Moonshot,
15
+ * Ollama, etc.).
16
+ *
17
+ * The client follows the same fail-safe pattern as {@link NotificationsClient}:
18
+ *
19
+ * - It is disabled when `apiKey`, `apiUrl` or `model` is missing, so a
20
+ * partially configured parent application still starts.
21
+ * - The integration status is logged exactly once, at construction time.
22
+ * - Calling `request` on a disabled client throws, since an LLM call that
23
+ * silently returns nothing is rarely what the caller wants. Check
24
+ * `isEnabled()` before relying on the client.
25
+ * - Provider errors are rethrown as `Error` with the provider message when
26
+ * available, so callers get actionable failure reasons.
27
+ *
28
+ * @example
29
+ * ```ts
30
+ * const llm = new LLMClient({
31
+ * apiKey: config.LLM_API_KEY,
32
+ * apiUrl: config.LLM_API_URL,
33
+ * model: config.LLM_MODEL,
34
+ * logger: OTelLogger().createModuleLogger("llm"),
35
+ * });
36
+ *
37
+ * if (llm.isEnabled()) {
38
+ * const response = await llm.request([
39
+ * { role: "system", content: "You summarize text." },
40
+ * { role: "user", content: someText },
41
+ * ]);
42
+ * console.log(response.content, response.totalTokens);
43
+ * }
44
+ * ```
45
+ */
46
+ class LLMClient {
47
+ constructor(config) {
48
+ this.client = null;
49
+ this.enabled = !!(config.apiKey && config.apiUrl && config.model);
50
+ this.model = config.model;
51
+ this.timeoutMs = config.timeoutMs || 120000;
52
+ this.logger = config.logger || consoleLogger;
53
+ if (this.enabled) {
54
+ this.client = axios_1.default.create({
55
+ baseURL: config.apiUrl,
56
+ headers: {
57
+ "Content-Type": "application/json",
58
+ Authorization: `Bearer ${config.apiKey}`,
59
+ },
60
+ timeout: this.timeoutMs,
61
+ });
62
+ this.logger.info(`LLM integration enabled (model: ${this.model})`);
63
+ }
64
+ else {
65
+ this.logger.info("LLM integration disabled (apiKey, apiUrl or model not set)");
66
+ }
67
+ }
68
+ /**
69
+ * Check whether the client is properly configured.
70
+ */
71
+ isEnabled() {
72
+ return this.enabled;
73
+ }
74
+ /**
75
+ * Send a chat completion request.
76
+ *
77
+ * @param messages The chat messages to send.
78
+ * @param options Optional per-request overrides (JSON mode, model).
79
+ * @returns The first choice content and total token usage.
80
+ * @throws When the client is disabled, the provider returns an error, or
81
+ * the response has no choices.
82
+ */
83
+ async request(messages, options) {
84
+ var _a, _b, _c, _d, _e;
85
+ if (!this.enabled || !this.client) {
86
+ throw new Error("LLM integration disabled (apiKey, apiUrl or model not set)");
87
+ }
88
+ const model = (options === null || options === void 0 ? void 0 : options.model) || this.model;
89
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
90
+ const body = {
91
+ model,
92
+ messages,
93
+ stream: false,
94
+ };
95
+ if (options === null || options === void 0 ? void 0 : options.jsonMode) {
96
+ body.response_format = { type: "json_object" };
97
+ }
98
+ try {
99
+ const response = await this.client.post("", body);
100
+ const choice = (_b = (_a = response.data) === null || _a === void 0 ? void 0 : _a.choices) === null || _b === void 0 ? void 0 : _b[0];
101
+ if (!choice) {
102
+ throw new Error("LLM response has no choices");
103
+ }
104
+ return {
105
+ content: ((_c = choice.message) === null || _c === void 0 ? void 0 : _c.content) || "",
106
+ totalTokens: ((_e = (_d = response.data) === null || _d === void 0 ? void 0 : _d.usage) === null || _e === void 0 ? void 0 : _e.total_tokens) || 0,
107
+ };
108
+ }
109
+ catch (err) {
110
+ const message = this.extractErrorMessage(err);
111
+ this.logger.error(`LLMClient: request failed (${message})`, err);
112
+ throw new Error(message, { cause: err });
113
+ }
114
+ }
115
+ /**
116
+ * Extract a readable error message, preferring the provider's error
117
+ * payload when the failure comes from an HTTP response.
118
+ */
119
+ extractErrorMessage(err) {
120
+ var _a, _b, _c;
121
+ if (axios_1.default.isAxiosError(err)) {
122
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
123
+ const providerMessage = (_c = (_b = (_a = err.response) === null || _a === void 0 ? void 0 : _a.data) === null || _b === void 0 ? void 0 : _b.error) === null || _c === void 0 ? void 0 : _c.message;
124
+ if (providerMessage) {
125
+ return providerMessage;
126
+ }
127
+ }
128
+ return err instanceof Error ? err.message : String(err);
129
+ }
130
+ }
131
+ exports.LLMClient = LLMClient;
@@ -71,7 +71,7 @@ async function AuthInit(context, configIn, allScopes = []) {
71
71
  const authKeyRaw = await (0, DbUtils_1.DbUtilsQuerySQL)(span, SQL_QUERIES.GET_AUTH_TOKEN);
72
72
  if (authKeyRaw.length == 0) {
73
73
  configIn.JWT_KEY = (0, uuid_1.v4)();
74
- await (0, DbUtils_1.DbUtilsQuerySQL)(span, SQL_QUERIES.INSERT_AUTH_TOKEN, [
74
+ await (0, DbUtils_1.DbUtilsExecSQL)(span, SQL_QUERIES.INSERT_AUTH_TOKEN, [
75
75
  configIn.JWT_KEY,
76
76
  new Date().toISOString(),
77
77
  ]);
package/index.ts CHANGED
@@ -5,6 +5,7 @@ export * from "./src/DbUtilsNoTelemetry";
5
5
  export * from "./src/SqlDbUtils";
6
6
  export * from "./src/PostgresDbUtils";
7
7
  export * from "./src/Notifications";
8
+ export * from "./src/LLM";
8
9
  export * from "./src/SystemCommand";
9
10
  export * from "./src/Timeout";
10
11
  export * from "./src/users/User";
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@devopsplaybook.io/common-utils",
3
- "version": "1.4.0",
4
- "description": "Shared utility modules for devopsplaybook.io projects (DB, Config, OTel context, auth/users, notifications, system helpers)",
3
+ "version": "1.5.0-beta.14.dd2303d",
4
+ "description": "Shared utility modules for devopsplaybook.io projects (DB, Config, OTel context, auth/users, notifications, LLM, system helpers)",
5
5
  "keywords": [
6
6
  "Open Telemetry",
7
7
  "OTel",
@@ -12,6 +12,7 @@
12
12
  "JWT",
13
13
  "Users",
14
14
  "Notifications",
15
+ "LLM",
15
16
  "Utilities"
16
17
  ],
17
18
  "license": "ISC",
@@ -0,0 +1,248 @@
1
+ jest.mock("axios", () => ({
2
+ create: jest.fn(),
3
+ isAxiosError: (err: unknown) =>
4
+ typeof err === "object" &&
5
+ err !== null &&
6
+ (err as { isAxiosError?: boolean }).isAxiosError === true,
7
+ }));
8
+
9
+ import axios from "axios";
10
+ import { LLMClient, LLMLogger } from "./LLM";
11
+
12
+ const mockedCreate = axios.create as jest.MockedFunction<typeof axios.create>;
13
+
14
+ /** Logger double that records every call. */
15
+ function createMockLogger(): LLMLogger & {
16
+ info: jest.Mock;
17
+ error: jest.Mock;
18
+ } {
19
+ return {
20
+ info: jest.fn(),
21
+ error: jest.fn(),
22
+ };
23
+ }
24
+
25
+ describe("LLMClient", () => {
26
+ let mockPost: jest.Mock;
27
+ let mockLogger: ReturnType<typeof createMockLogger>;
28
+
29
+ beforeEach(() => {
30
+ jest.clearAllMocks();
31
+ mockPost = jest.fn();
32
+ mockedCreate.mockReturnValue({ post: mockPost } as never);
33
+ mockLogger = createMockLogger();
34
+ });
35
+
36
+ function createEnabledClient(): LLMClient {
37
+ return new LLMClient({
38
+ apiKey: "key",
39
+ apiUrl: "https://api.example.com/chat/completions",
40
+ model: "test-model",
41
+ logger: mockLogger,
42
+ });
43
+ }
44
+
45
+ describe("constructor", () => {
46
+ it("should be enabled when apiKey, apiUrl and model are set", () => {
47
+ const client = createEnabledClient();
48
+
49
+ expect(client.isEnabled()).toBe(true);
50
+ expect(mockedCreate).toHaveBeenCalledTimes(1);
51
+ });
52
+
53
+ it("should create the HTTP client with auth header and timeout", () => {
54
+ new LLMClient({
55
+ apiKey: "key",
56
+ apiUrl: "https://api.example.com/chat/completions",
57
+ model: "test-model",
58
+ timeoutMs: 42000,
59
+ logger: mockLogger,
60
+ });
61
+
62
+ expect(mockedCreate).toHaveBeenCalledWith({
63
+ baseURL: "https://api.example.com/chat/completions",
64
+ headers: {
65
+ "Content-Type": "application/json",
66
+ Authorization: "Bearer key",
67
+ },
68
+ timeout: 42000,
69
+ });
70
+ });
71
+
72
+ it("should default the timeout to 120000 ms", () => {
73
+ createEnabledClient();
74
+
75
+ expect(mockedCreate).toHaveBeenCalledWith(
76
+ expect.objectContaining({ timeout: 120000 }),
77
+ );
78
+ });
79
+
80
+ it.each([
81
+ ["apiKey", { apiKey: "" }],
82
+ ["apiUrl", { apiUrl: "" }],
83
+ ["model", { model: "" }],
84
+ ])("should be disabled when %s is empty", (_name, override) => {
85
+ const client = new LLMClient({
86
+ apiKey: "key",
87
+ apiUrl: "https://api.example.com/chat/completions",
88
+ model: "test-model",
89
+ logger: mockLogger,
90
+ ...override,
91
+ });
92
+
93
+ expect(client.isEnabled()).toBe(false);
94
+ expect(mockedCreate).not.toHaveBeenCalled();
95
+ });
96
+
97
+ it("should log the integration status exactly once at construction", () => {
98
+ createEnabledClient();
99
+
100
+ expect(mockLogger.info).toHaveBeenCalledTimes(1);
101
+ expect(mockLogger.info).toHaveBeenCalledWith(
102
+ "LLM integration enabled (model: test-model)",
103
+ );
104
+ });
105
+
106
+ it("should log the disabled status when misconfigured", () => {
107
+ new LLMClient({
108
+ apiKey: "",
109
+ apiUrl: "",
110
+ model: "",
111
+ logger: mockLogger,
112
+ });
113
+
114
+ expect(mockLogger.info).toHaveBeenCalledWith(
115
+ "LLM integration disabled (apiKey, apiUrl or model not set)",
116
+ );
117
+ });
118
+ });
119
+
120
+ describe("request", () => {
121
+ it("should throw when the client is disabled", async () => {
122
+ const client = new LLMClient({
123
+ apiKey: "",
124
+ apiUrl: "",
125
+ model: "",
126
+ logger: mockLogger,
127
+ });
128
+
129
+ await expect(
130
+ client.request([{ role: "user", content: "hello" }]),
131
+ ).rejects.toThrow("LLM integration disabled");
132
+ expect(mockPost).not.toHaveBeenCalled();
133
+ });
134
+
135
+ it("should send model and messages without response_format by default", async () => {
136
+ mockPost.mockResolvedValue({
137
+ data: {
138
+ choices: [{ message: { content: "hi there" } }],
139
+ usage: { total_tokens: 12 },
140
+ },
141
+ });
142
+ const client = createEnabledClient();
143
+
144
+ const response = await client.request([
145
+ { role: "system", content: "be brief" },
146
+ { role: "user", content: "hello" },
147
+ ]);
148
+
149
+ expect(mockPost).toHaveBeenCalledWith("", {
150
+ model: "test-model",
151
+ messages: [
152
+ { role: "system", content: "be brief" },
153
+ { role: "user", content: "hello" },
154
+ ],
155
+ stream: false,
156
+ });
157
+ expect(response).toEqual({ content: "hi there", totalTokens: 12 });
158
+ });
159
+
160
+ it("should add response_format json_object in jsonMode", async () => {
161
+ mockPost.mockResolvedValue({
162
+ data: {
163
+ choices: [{ message: { content: "{}" } }],
164
+ usage: { total_tokens: 1 },
165
+ },
166
+ });
167
+ const client = createEnabledClient();
168
+
169
+ await client.request([{ role: "user", content: "hello" }], {
170
+ jsonMode: true,
171
+ });
172
+
173
+ expect(mockPost).toHaveBeenCalledWith(
174
+ "",
175
+ expect.objectContaining({
176
+ response_format: { type: "json_object" },
177
+ }),
178
+ );
179
+ });
180
+
181
+ it("should override the model per request", async () => {
182
+ mockPost.mockResolvedValue({
183
+ data: {
184
+ choices: [{ message: { content: "ok" } }],
185
+ usage: { total_tokens: 1 },
186
+ },
187
+ });
188
+ const client = createEnabledClient();
189
+
190
+ await client.request([{ role: "user", content: "hello" }], {
191
+ model: "other-model",
192
+ });
193
+
194
+ expect(mockPost).toHaveBeenCalledWith(
195
+ "",
196
+ expect.objectContaining({ model: "other-model" }),
197
+ );
198
+ });
199
+
200
+ it("should default totalTokens to 0 and content to empty string", async () => {
201
+ mockPost.mockResolvedValue({
202
+ data: { choices: [{ message: { content: null } }] },
203
+ });
204
+ const client = createEnabledClient();
205
+
206
+ const response = await client.request([
207
+ { role: "user", content: "hello" },
208
+ ]);
209
+
210
+ expect(response).toEqual({ content: "", totalTokens: 0 });
211
+ });
212
+
213
+ it("should throw when the response has no choices", async () => {
214
+ mockPost.mockResolvedValue({ data: {} });
215
+ const client = createEnabledClient();
216
+
217
+ await expect(
218
+ client.request([{ role: "user", content: "hello" }]),
219
+ ).rejects.toThrow("LLM response has no choices");
220
+ });
221
+
222
+ it("should surface the provider error message", async () => {
223
+ const axiosError = Object.assign(new Error("Request failed 401"), {
224
+ isAxiosError: true,
225
+ response: { data: { error: { message: "Invalid API key" } } },
226
+ });
227
+ mockPost.mockRejectedValue(axiosError);
228
+ const client = createEnabledClient();
229
+
230
+ await expect(
231
+ client.request([{ role: "user", content: "hello" }]),
232
+ ).rejects.toThrow("Invalid API key");
233
+ expect(mockLogger.error).toHaveBeenCalledWith(
234
+ "LLMClient: request failed (Invalid API key)",
235
+ axiosError,
236
+ );
237
+ });
238
+
239
+ it("should keep the original message for non-provider errors", async () => {
240
+ mockPost.mockRejectedValue(new Error("Network down"));
241
+ const client = createEnabledClient();
242
+
243
+ await expect(
244
+ client.request([{ role: "user", content: "hello" }]),
245
+ ).rejects.toThrow("Network down");
246
+ });
247
+ });
248
+ });
package/src/LLM.ts ADDED
@@ -0,0 +1,195 @@
1
+ import axios, { AxiosInstance } from "axios";
2
+
3
+ /**
4
+ * A single chat message in OpenAI-compatible format.
5
+ */
6
+ export interface LLMMessage {
7
+ /** Message role, e.g. "system", "user", "assistant" */
8
+ role: string;
9
+ /** Message content */
10
+ content: string;
11
+ }
12
+
13
+ /**
14
+ * Minimal logger interface expected by the LLM client.
15
+ * Matches the subset of the OTel logger used by devopsplaybook.io projects.
16
+ */
17
+ export interface LLMLogger {
18
+ info(message: string): void;
19
+ error(message: string, err?: unknown): void;
20
+ }
21
+
22
+ /**
23
+ * Configuration for {@link LLMClient}.
24
+ */
25
+ export interface LLMClientConfig {
26
+ /** API key used for Bearer authentication */
27
+ apiKey: string;
28
+ /** Chat completions endpoint URL (e.g., "https://api.deepseek.com/chat/completions") */
29
+ apiUrl: string;
30
+ /** Model name to use (e.g., "deepseek-chat") */
31
+ model: string;
32
+ /** Request timeout in milliseconds (defaults to 120000) */
33
+ timeoutMs?: number;
34
+ /** Optional logger; falls back to console when omitted */
35
+ logger?: LLMLogger;
36
+ }
37
+
38
+ /**
39
+ * Per-request options for {@link LLMClient.request}.
40
+ */
41
+ export interface LLMRequestOptions {
42
+ /** Request JSON output (`response_format: json_object`). Defaults to false. */
43
+ jsonMode?: boolean;
44
+ /** Override the configured model for this call */
45
+ model?: string;
46
+ }
47
+
48
+ /**
49
+ * Normalized response from a chat completion call.
50
+ */
51
+ export interface LLMResponse {
52
+ /** Content of the first choice (empty string when the model returned none) */
53
+ content: string;
54
+ /** Total tokens used, as reported by the provider (0 when unavailable) */
55
+ totalTokens: number;
56
+ }
57
+
58
+ /** Console fallback used when no logger is injected. */
59
+ const consoleLogger: LLMLogger = {
60
+ info: (message: string) => console.log(message),
61
+ error: (message: string, err?: unknown) => console.error(message, err),
62
+ };
63
+
64
+ /**
65
+ * Client for OpenAI-compatible chat completions APIs (DeepSeek, Moonshot,
66
+ * Ollama, etc.).
67
+ *
68
+ * The client follows the same fail-safe pattern as {@link NotificationsClient}:
69
+ *
70
+ * - It is disabled when `apiKey`, `apiUrl` or `model` is missing, so a
71
+ * partially configured parent application still starts.
72
+ * - The integration status is logged exactly once, at construction time.
73
+ * - Calling `request` on a disabled client throws, since an LLM call that
74
+ * silently returns nothing is rarely what the caller wants. Check
75
+ * `isEnabled()` before relying on the client.
76
+ * - Provider errors are rethrown as `Error` with the provider message when
77
+ * available, so callers get actionable failure reasons.
78
+ *
79
+ * @example
80
+ * ```ts
81
+ * const llm = new LLMClient({
82
+ * apiKey: config.LLM_API_KEY,
83
+ * apiUrl: config.LLM_API_URL,
84
+ * model: config.LLM_MODEL,
85
+ * logger: OTelLogger().createModuleLogger("llm"),
86
+ * });
87
+ *
88
+ * if (llm.isEnabled()) {
89
+ * const response = await llm.request([
90
+ * { role: "system", content: "You summarize text." },
91
+ * { role: "user", content: someText },
92
+ * ]);
93
+ * console.log(response.content, response.totalTokens);
94
+ * }
95
+ * ```
96
+ */
97
+ export class LLMClient {
98
+ private client: AxiosInstance | null = null;
99
+ private readonly enabled: boolean;
100
+ private readonly model: string;
101
+ private readonly timeoutMs: number;
102
+ private readonly logger: LLMLogger;
103
+
104
+ constructor(config: LLMClientConfig) {
105
+ this.enabled = !!(config.apiKey && config.apiUrl && config.model);
106
+ this.model = config.model;
107
+ this.timeoutMs = config.timeoutMs || 120000;
108
+ this.logger = config.logger || consoleLogger;
109
+
110
+ if (this.enabled) {
111
+ this.client = axios.create({
112
+ baseURL: config.apiUrl,
113
+ headers: {
114
+ "Content-Type": "application/json",
115
+ Authorization: `Bearer ${config.apiKey}`,
116
+ },
117
+ timeout: this.timeoutMs,
118
+ });
119
+ this.logger.info(`LLM integration enabled (model: ${this.model})`);
120
+ } else {
121
+ this.logger.info(
122
+ "LLM integration disabled (apiKey, apiUrl or model not set)",
123
+ );
124
+ }
125
+ }
126
+
127
+ /**
128
+ * Check whether the client is properly configured.
129
+ */
130
+ public isEnabled(): boolean {
131
+ return this.enabled;
132
+ }
133
+
134
+ /**
135
+ * Send a chat completion request.
136
+ *
137
+ * @param messages The chat messages to send.
138
+ * @param options Optional per-request overrides (JSON mode, model).
139
+ * @returns The first choice content and total token usage.
140
+ * @throws When the client is disabled, the provider returns an error, or
141
+ * the response has no choices.
142
+ */
143
+ public async request(
144
+ messages: LLMMessage[],
145
+ options?: LLMRequestOptions,
146
+ ): Promise<LLMResponse> {
147
+ if (!this.enabled || !this.client) {
148
+ throw new Error(
149
+ "LLM integration disabled (apiKey, apiUrl or model not set)",
150
+ );
151
+ }
152
+
153
+ const model = options?.model || this.model;
154
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
155
+ const body: Record<string, any> = {
156
+ model,
157
+ messages,
158
+ stream: false,
159
+ };
160
+ if (options?.jsonMode) {
161
+ body.response_format = { type: "json_object" };
162
+ }
163
+
164
+ try {
165
+ const response = await this.client.post("", body);
166
+ const choice = response.data?.choices?.[0];
167
+ if (!choice) {
168
+ throw new Error("LLM response has no choices");
169
+ }
170
+ return {
171
+ content: choice.message?.content || "",
172
+ totalTokens: response.data?.usage?.total_tokens || 0,
173
+ };
174
+ } catch (err) {
175
+ const message = this.extractErrorMessage(err);
176
+ this.logger.error(`LLMClient: request failed (${message})`, err);
177
+ throw new Error(message, { cause: err });
178
+ }
179
+ }
180
+
181
+ /**
182
+ * Extract a readable error message, preferring the provider's error
183
+ * payload when the failure comes from an HTTP response.
184
+ */
185
+ private extractErrorMessage(err: unknown): string {
186
+ if (axios.isAxiosError(err)) {
187
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
188
+ const providerMessage = (err.response?.data as any)?.error?.message;
189
+ if (providerMessage) {
190
+ return providerMessage;
191
+ }
192
+ }
193
+ return err instanceof Error ? err.message : String(err);
194
+ }
195
+ }
package/src/users/Auth.ts CHANGED
@@ -2,7 +2,7 @@ import { StandardTracer } from "@devopsplaybook.io/otel-utils";
2
2
  import { Span } from "@opentelemetry/sdk-trace-base";
3
3
  import * as jwt from "jsonwebtoken";
4
4
  import { v4 as uuidv4 } from "uuid";
5
- import { DbUtilsQuerySQL } from "../DbUtils";
5
+ import { DbUtilsExecSQL, DbUtilsQuerySQL } from "../DbUtils";
6
6
  import { User, UserScope } from "./User";
7
7
  import { UserSession } from "./UserSession";
8
8
 
@@ -48,7 +48,7 @@ export async function AuthInit(
48
48
  const authKeyRaw = await DbUtilsQuerySQL(span, SQL_QUERIES.GET_AUTH_TOKEN);
49
49
  if (authKeyRaw.length == 0) {
50
50
  configIn.JWT_KEY = uuidv4();
51
- await DbUtilsQuerySQL(span, SQL_QUERIES.INSERT_AUTH_TOKEN, [
51
+ await DbUtilsExecSQL(span, SQL_QUERIES.INSERT_AUTH_TOKEN, [
52
52
  configIn.JWT_KEY,
53
53
  new Date().toISOString(),
54
54
  ]);
package/tsconfig.json CHANGED
@@ -1,6 +1,7 @@
1
1
  {
2
2
  "compilerOptions": {
3
3
  "target": "ES2019",
4
+ "lib": ["ES2019", "ES2022.Error"],
4
5
  "module": "commonjs",
5
6
  "declaration": true,
6
7
  "outDir": "./dist",