@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 +1 -0
- package/README.md +43 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/src/LLM.d.ts +110 -0
- package/dist/src/LLM.js +131 -0
- package/dist/src/users/Auth.js +1 -1
- package/index.ts +1 -0
- package/package.json +3 -2
- package/src/LLM.spec.ts +248 -0
- package/src/LLM.ts +195 -0
- package/src/users/Auth.ts +2 -2
- package/tsconfig.json +1 -0
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
|
|
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
|
+
}
|
package/dist/src/LLM.js
ADDED
|
@@ -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;
|
package/dist/src/users/Auth.js
CHANGED
|
@@ -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.
|
|
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
|
-
"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",
|
package/src/LLM.spec.ts
ADDED
|
@@ -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
|
|
51
|
+
await DbUtilsExecSQL(span, SQL_QUERIES.INSERT_AUTH_TOKEN, [
|
|
52
52
|
configIn.JWT_KEY,
|
|
53
53
|
new Date().toISOString(),
|
|
54
54
|
]);
|