@khirby/plugin-ai-compose 1.0.0 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/src/ai-compose-crypto.ts +5 -60
- package/src/ai-compose-llm.service.ts +54 -0
- package/src/ai-compose-settings.controller.ts +2 -4
- package/src/ai-compose-settings.service.ts +41 -7
- package/src/ai-compose-suggest.service.ts +88 -183
- package/src/ai-compose.module.ts +11 -2
- package/src/ai-compose.spec.ts +232 -144
- package/src/migrations.ts +3 -1
- package/src/schema.ts +1 -0
- package/LICENSE +0 -21
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@khirby/plugin-ai-compose",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"description": "Khirby — AI-powered reply draft suggestions (BYOK, OpenAI-compatible)",
|
|
5
5
|
"main": "src/index.ts",
|
|
6
6
|
"keywords": [
|
|
@@ -21,4 +21,4 @@
|
|
|
21
21
|
"access": "public",
|
|
22
22
|
"registry": "https://registry.npmjs.org"
|
|
23
23
|
}
|
|
24
|
-
}
|
|
24
|
+
}
|
package/src/ai-compose-crypto.ts
CHANGED
|
@@ -1,60 +1,5 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
function getKey(): Buffer {
|
|
8
|
-
const raw = process.env.AI_COMPOSE_SECRETS_KEY?.trim();
|
|
9
|
-
if (!raw) {
|
|
10
|
-
throw new Error('AI_COMPOSE_SECRETS_KEY is not set');
|
|
11
|
-
}
|
|
12
|
-
let buf: Buffer;
|
|
13
|
-
if (/^[0-9a-fA-F]{64}$/.test(raw)) {
|
|
14
|
-
buf = Buffer.from(raw, 'hex');
|
|
15
|
-
} else {
|
|
16
|
-
buf = Buffer.from(raw, 'base64');
|
|
17
|
-
}
|
|
18
|
-
if (buf.length !== 32) {
|
|
19
|
-
throw new Error(
|
|
20
|
-
"AI_COMPOSE_SECRETS_KEY must be 32 bytes as hex (64 chars) or base64 — generate with: node -e \"console.log(require('crypto').randomBytes(32).toString('hex'))\"",
|
|
21
|
-
);
|
|
22
|
-
}
|
|
23
|
-
return buf;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
export function isAiComposeSecretsKeyConfigured(): boolean {
|
|
27
|
-
try {
|
|
28
|
-
getKey();
|
|
29
|
-
return true;
|
|
30
|
-
} catch {
|
|
31
|
-
return false;
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
/**
|
|
36
|
-
* Encrypts plaintext using AES-256-GCM.
|
|
37
|
-
* Returns a base64 string: iv(12) + ciphertext + tag(16)
|
|
38
|
-
*/
|
|
39
|
-
export function encrypt(plaintext: string): string {
|
|
40
|
-
const key = getKey();
|
|
41
|
-
const iv = randomBytes(IV_BYTES);
|
|
42
|
-
const cipher = createCipheriv(ALGORITHM, key, iv);
|
|
43
|
-
const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
|
|
44
|
-
const tag = cipher.getAuthTag();
|
|
45
|
-
return Buffer.concat([iv, encrypted, tag]).toString('base64');
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
/**
|
|
49
|
-
* Decrypts a base64 blob produced by `encrypt`.
|
|
50
|
-
*/
|
|
51
|
-
export function decrypt(ciphertext: string): string {
|
|
52
|
-
const key = getKey();
|
|
53
|
-
const buf = Buffer.from(ciphertext, 'base64');
|
|
54
|
-
const iv = buf.subarray(0, IV_BYTES);
|
|
55
|
-
const tag = buf.subarray(buf.length - TAG_BYTES);
|
|
56
|
-
const encrypted = buf.subarray(IV_BYTES, buf.length - TAG_BYTES);
|
|
57
|
-
const decipher = createDecipheriv(ALGORITHM, key, iv);
|
|
58
|
-
decipher.setAuthTag(tag);
|
|
59
|
-
return decipher.update(encrypted) + decipher.final('utf8');
|
|
60
|
-
}
|
|
1
|
+
export {
|
|
2
|
+
decrypt,
|
|
3
|
+
encrypt,
|
|
4
|
+
isInstanceSecretsKeyConfigured as isAiComposeSecretsKeyConfigured,
|
|
5
|
+
} from '../../../packages/plugin-host/src/instance-secrets';
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { Injectable, Logger } from '@nestjs/common';
|
|
2
|
+
import {
|
|
3
|
+
AppException,
|
|
4
|
+
isReasoningEffort,
|
|
5
|
+
type AiComposeLlmLike,
|
|
6
|
+
} from '../../../packages/plugin-host/src';
|
|
7
|
+
import { AiComposeSettingsService } from './ai-compose-settings.service';
|
|
8
|
+
import { AiComposeSuggestService } from './ai-compose-suggest.service';
|
|
9
|
+
|
|
10
|
+
/** Host token surface for Ask Khirby agent chat (ADR-0040). */
|
|
11
|
+
@Injectable()
|
|
12
|
+
export class AiComposeLlmService implements AiComposeLlmLike {
|
|
13
|
+
private readonly logger = new Logger(AiComposeLlmService.name);
|
|
14
|
+
|
|
15
|
+
constructor(
|
|
16
|
+
private readonly settings: AiComposeSettingsService,
|
|
17
|
+
private readonly suggest: AiComposeSuggestService,
|
|
18
|
+
) {}
|
|
19
|
+
|
|
20
|
+
async getCompletionConfig(): Promise<{
|
|
21
|
+
baseUrl: string;
|
|
22
|
+
apiKey: string;
|
|
23
|
+
model: string;
|
|
24
|
+
reasoningEffort?: 'none' | 'low' | 'medium' | 'high' | null;
|
|
25
|
+
reasoningSupported?: boolean | null;
|
|
26
|
+
} | null> {
|
|
27
|
+
await this.settings.assertPluginEnabled();
|
|
28
|
+
const { apiKey, baseUrl } = await this.settings.getDecryptedApiKey();
|
|
29
|
+
const model = await this.settings.getDefaultModel();
|
|
30
|
+
if (!model?.trim()) {
|
|
31
|
+
throw AppException.pluginNotConfigured('ai-compose', 'No default model configured');
|
|
32
|
+
}
|
|
33
|
+
const reasoningEffort = await this.settings.getReasoningEffort();
|
|
34
|
+
let reasoningSupported: boolean | null | undefined;
|
|
35
|
+
if (isReasoningEffort(reasoningEffort)) {
|
|
36
|
+
try {
|
|
37
|
+
reasoningSupported =
|
|
38
|
+
this.suggest.cachedReasoningSupport?.(baseUrl, model.trim()) ?? null;
|
|
39
|
+
} catch (err) {
|
|
40
|
+
this.logger.warn(
|
|
41
|
+
`Reasoning catalog cache unavailable: ${err instanceof Error ? err.message : 'unknown'}`,
|
|
42
|
+
);
|
|
43
|
+
reasoningSupported = null;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return {
|
|
47
|
+
baseUrl,
|
|
48
|
+
apiKey,
|
|
49
|
+
model: model.trim(),
|
|
50
|
+
reasoningEffort,
|
|
51
|
+
reasoningSupported,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -6,7 +6,6 @@ import {
|
|
|
6
6
|
RequirePermission,
|
|
7
7
|
RequirePluginEnabled,
|
|
8
8
|
PluginEnabledGuard,
|
|
9
|
-
AppException,
|
|
10
9
|
} from '../../../packages/plugin-host/src';
|
|
11
10
|
import { AiComposeSettingsService } from './ai-compose-settings.service';
|
|
12
11
|
import { AiComposeSuggestService } from './ai-compose-suggest.service';
|
|
@@ -39,6 +38,7 @@ export class AiComposeSettingsController {
|
|
|
39
38
|
defaultModel?: string | null;
|
|
40
39
|
allowedModels?: string[];
|
|
41
40
|
systemPrompt?: string | null;
|
|
41
|
+
reasoningEffort?: 'none' | 'low' | 'medium' | 'high' | null;
|
|
42
42
|
},
|
|
43
43
|
) {
|
|
44
44
|
return this.settings.updateSettings(dto);
|
|
@@ -47,9 +47,7 @@ export class AiComposeSettingsController {
|
|
|
47
47
|
@Get('models')
|
|
48
48
|
@ApiOperation({ summary: 'Fetch available models from the configured AI provider' })
|
|
49
49
|
async getModels() {
|
|
50
|
-
const { apiKey, baseUrl } = await this.settings.getDecryptedApiKey()
|
|
51
|
-
throw AppException.badRequest('API key is not configured. Save your settings first.');
|
|
52
|
-
});
|
|
50
|
+
const { apiKey, baseUrl } = await this.settings.getDecryptedApiKey();
|
|
53
51
|
return this.suggest.fetchModels(baseUrl, apiKey);
|
|
54
52
|
}
|
|
55
53
|
|
|
@@ -6,6 +6,8 @@ import {
|
|
|
6
6
|
PLUGIN_REGISTRY,
|
|
7
7
|
type PluginRegistryLike,
|
|
8
8
|
AppException,
|
|
9
|
+
isReasoningEffort,
|
|
10
|
+
type ReasoningEffort,
|
|
9
11
|
} from '../../../packages/plugin-host/src';
|
|
10
12
|
import { aiComposeSettings } from './schema';
|
|
11
13
|
import { encrypt, decrypt, isAiComposeSecretsKeyConfigured } from './ai-compose-crypto';
|
|
@@ -17,6 +19,7 @@ export type AiComposeSettingsPublic = {
|
|
|
17
19
|
defaultModel: string | null;
|
|
18
20
|
allowedModels: string[];
|
|
19
21
|
systemPrompt: string | null;
|
|
22
|
+
reasoningEffort: ReasoningEffort | null;
|
|
20
23
|
apiKeyConfigured: boolean;
|
|
21
24
|
};
|
|
22
25
|
|
|
@@ -49,6 +52,7 @@ export class AiComposeSettingsService {
|
|
|
49
52
|
defaultModel: row?.defaultModel ?? null,
|
|
50
53
|
allowedModels: row?.allowedModels ?? [],
|
|
51
54
|
systemPrompt: row?.systemPrompt ?? null,
|
|
55
|
+
reasoningEffort: isReasoningEffort(row?.reasoningEffort) ? row.reasoningEffort : null,
|
|
52
56
|
apiKeyConfigured: !!row?.apiKeyEnc,
|
|
53
57
|
};
|
|
54
58
|
}
|
|
@@ -59,6 +63,7 @@ export class AiComposeSettingsService {
|
|
|
59
63
|
defaultModel?: string | null;
|
|
60
64
|
allowedModels?: string[];
|
|
61
65
|
systemPrompt?: string | null;
|
|
66
|
+
reasoningEffort?: ReasoningEffort | null;
|
|
62
67
|
}): Promise<AiComposeSettingsPublic> {
|
|
63
68
|
await this.assertPluginEnabled();
|
|
64
69
|
|
|
@@ -67,7 +72,7 @@ export class AiComposeSettingsService {
|
|
|
67
72
|
let apiKeyEnc: string | undefined = undefined;
|
|
68
73
|
if (dto.apiKey !== undefined && dto.apiKey.trim()) {
|
|
69
74
|
if (!isAiComposeSecretsKeyConfigured()) {
|
|
70
|
-
throw AppException.badRequest('
|
|
75
|
+
throw AppException.badRequest('KHIRBY_SECRETS_KEY is not configured');
|
|
71
76
|
}
|
|
72
77
|
apiKeyEnc = encrypt(dto.apiKey.trim());
|
|
73
78
|
}
|
|
@@ -83,6 +88,14 @@ export class AiComposeSettingsService {
|
|
|
83
88
|
);
|
|
84
89
|
}
|
|
85
90
|
|
|
91
|
+
if (
|
|
92
|
+
dto.reasoningEffort !== undefined &&
|
|
93
|
+
dto.reasoningEffort !== null &&
|
|
94
|
+
!isReasoningEffort(dto.reasoningEffort)
|
|
95
|
+
) {
|
|
96
|
+
throw AppException.badRequest('reasoningEffort must be none, low, medium, or high');
|
|
97
|
+
}
|
|
98
|
+
|
|
86
99
|
const patch: Record<string, unknown> = {
|
|
87
100
|
baseUrl,
|
|
88
101
|
defaultModel:
|
|
@@ -90,6 +103,12 @@ export class AiComposeSettingsService {
|
|
|
90
103
|
allowedModels: dto.allowedModels ?? existing?.allowedModels ?? [],
|
|
91
104
|
systemPrompt:
|
|
92
105
|
dto.systemPrompt !== undefined ? dto.systemPrompt : (existing?.systemPrompt ?? null),
|
|
106
|
+
reasoningEffort:
|
|
107
|
+
dto.reasoningEffort !== undefined
|
|
108
|
+
? dto.reasoningEffort
|
|
109
|
+
: isReasoningEffort(existing?.reasoningEffort)
|
|
110
|
+
? existing.reasoningEffort
|
|
111
|
+
: null,
|
|
93
112
|
updatedAt: new Date(),
|
|
94
113
|
};
|
|
95
114
|
|
|
@@ -110,16 +129,26 @@ export class AiComposeSettingsService {
|
|
|
110
129
|
return this.getSettings();
|
|
111
130
|
}
|
|
112
131
|
|
|
113
|
-
/** Decrypt the stored API key for internal use; throws if missing. */
|
|
132
|
+
/** Decrypt the stored API key for internal use; throws if missing or unreadable. */
|
|
114
133
|
async getDecryptedApiKey(): Promise<{ apiKey: string; baseUrl: string }> {
|
|
115
134
|
const row = await this.getRow();
|
|
116
135
|
if (!row?.apiKeyEnc) {
|
|
117
|
-
throw AppException.
|
|
136
|
+
throw AppException.pluginNotConfigured('ai-compose', 'AI Compose API key is not configured');
|
|
137
|
+
}
|
|
138
|
+
try {
|
|
139
|
+
return {
|
|
140
|
+
apiKey: decrypt(row.apiKeyEnc),
|
|
141
|
+
baseUrl: row.baseUrl,
|
|
142
|
+
};
|
|
143
|
+
} catch (err) {
|
|
144
|
+
this.logger.warn(
|
|
145
|
+
`Failed to decrypt AI Compose API key: ${err instanceof Error ? err.message : 'unknown'}`,
|
|
146
|
+
);
|
|
147
|
+
throw AppException.pluginNotConfigured(
|
|
148
|
+
'ai-compose',
|
|
149
|
+
'AI Compose API key cannot be decrypted',
|
|
150
|
+
);
|
|
118
151
|
}
|
|
119
|
-
return {
|
|
120
|
-
apiKey: decrypt(row.apiKeyEnc),
|
|
121
|
-
baseUrl: row.baseUrl,
|
|
122
|
-
};
|
|
123
152
|
}
|
|
124
153
|
|
|
125
154
|
async getAllowedModels(): Promise<string[]> {
|
|
@@ -136,4 +165,9 @@ export class AiComposeSettingsService {
|
|
|
136
165
|
const row = await this.getRow();
|
|
137
166
|
return row?.systemPrompt ?? null;
|
|
138
167
|
}
|
|
168
|
+
|
|
169
|
+
async getReasoningEffort(): Promise<ReasoningEffort | null> {
|
|
170
|
+
const row = await this.getRow();
|
|
171
|
+
return isReasoningEffort(row?.reasoningEffort) ? row.reasoningEffort : null;
|
|
172
|
+
}
|
|
139
173
|
}
|
|
@@ -2,10 +2,13 @@ import { Injectable, Inject, Logger, Optional } from '@nestjs/common';
|
|
|
2
2
|
import {
|
|
3
3
|
LEADS_SERVICE,
|
|
4
4
|
MAIL_THREAD_SERVICE,
|
|
5
|
-
|
|
5
|
+
KNOWLEDGE_CONTEXT,
|
|
6
6
|
type MailThreadServiceLike,
|
|
7
|
-
type
|
|
7
|
+
type KnowledgeContextLike,
|
|
8
8
|
AppException,
|
|
9
|
+
applyReasoningEffort,
|
|
10
|
+
isReasoningEffort,
|
|
11
|
+
parseModelReasoningSupport,
|
|
9
12
|
} from '../../../packages/plugin-host/src';
|
|
10
13
|
import { AiComposeSettingsService } from './ai-compose-settings.service';
|
|
11
14
|
|
|
@@ -33,14 +36,17 @@ export type LeadsServiceLike = {
|
|
|
33
36
|
@Injectable()
|
|
34
37
|
export class AiComposeSuggestService {
|
|
35
38
|
private readonly logger = new Logger(AiComposeSuggestService.name);
|
|
39
|
+
/** Advertised (or learned from a 400) reasoning support, keyed by baseUrl + model id. */
|
|
40
|
+
private readonly reasoningByModel = new Map<string, boolean>();
|
|
41
|
+
private readonly catalogFetched = new Set<string>();
|
|
36
42
|
|
|
37
43
|
constructor(
|
|
38
44
|
private readonly settings: AiComposeSettingsService,
|
|
39
45
|
@Inject(MAIL_THREAD_SERVICE) private readonly mailThreads: MailThreadServiceLike,
|
|
40
46
|
@Inject(LEADS_SERVICE) private readonly leads: LeadsServiceLike,
|
|
41
47
|
@Optional()
|
|
42
|
-
@Inject(
|
|
43
|
-
private readonly
|
|
48
|
+
@Inject(KNOWLEDGE_CONTEXT)
|
|
49
|
+
private readonly knowledge: KnowledgeContextLike | null,
|
|
44
50
|
) {}
|
|
45
51
|
|
|
46
52
|
async availability(): Promise<{ available: boolean; defaultModel: string | null }> {
|
|
@@ -218,7 +224,10 @@ export class AiComposeSuggestService {
|
|
|
218
224
|
return { draft, modelUsed };
|
|
219
225
|
}
|
|
220
226
|
|
|
221
|
-
async fetchModels(
|
|
227
|
+
async fetchModels(
|
|
228
|
+
baseUrl: string,
|
|
229
|
+
apiKey: string,
|
|
230
|
+
): Promise<{ id: string; label: string; supportsReasoning: boolean | null }[]> {
|
|
222
231
|
const response = await fetch(`${baseUrl}/models`, {
|
|
223
232
|
headers: { Authorization: `Bearer ${apiKey}` },
|
|
224
233
|
});
|
|
@@ -231,12 +240,40 @@ export class AiComposeSuggestService {
|
|
|
231
240
|
}
|
|
232
241
|
|
|
233
242
|
const data = (await response.json()) as {
|
|
234
|
-
data?: Array<{ id
|
|
243
|
+
data?: Array<Record<string, unknown> & { id?: string; object?: string }>;
|
|
235
244
|
};
|
|
236
245
|
|
|
246
|
+
this.catalogFetched.add(this.originKey(baseUrl));
|
|
247
|
+
|
|
237
248
|
return (data?.data ?? [])
|
|
238
|
-
.filter((m) => m.object === 'model' || !m.object)
|
|
239
|
-
.map((m) =>
|
|
249
|
+
.filter((m) => typeof m.id === 'string' && (m.object === 'model' || !m.object))
|
|
250
|
+
.map((m) => {
|
|
251
|
+
const id = String(m.id);
|
|
252
|
+
const support = parseModelReasoningSupport(m);
|
|
253
|
+
if (support !== undefined) {
|
|
254
|
+
this.reasoningByModel.set(this.supportKey(baseUrl, id), support);
|
|
255
|
+
}
|
|
256
|
+
return { id, label: id, supportsReasoning: support ?? null };
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
async reasoningSupportFor(
|
|
261
|
+
baseUrl: string,
|
|
262
|
+
apiKey: string,
|
|
263
|
+
model: string,
|
|
264
|
+
): Promise<boolean | undefined> {
|
|
265
|
+
const key = this.supportKey(baseUrl, model);
|
|
266
|
+
const cached = this.reasoningByModel.get(key);
|
|
267
|
+
if (cached !== undefined) return cached;
|
|
268
|
+
if (!this.catalogFetched.has(this.originKey(baseUrl))) {
|
|
269
|
+
await this.fetchModels(baseUrl, apiKey).catch(() => undefined);
|
|
270
|
+
}
|
|
271
|
+
return this.reasoningByModel.get(key);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** In-memory only — Ask Khirby must not block SSE on GET /models. */
|
|
275
|
+
cachedReasoningSupport(baseUrl: string, model: string): boolean | undefined {
|
|
276
|
+
return this.reasoningByModel.get(this.supportKey(baseUrl, model));
|
|
240
277
|
}
|
|
241
278
|
|
|
242
279
|
/** Allowed models for compose UIs that are not integrations admins. */
|
|
@@ -304,35 +341,53 @@ export class AiComposeSuggestService {
|
|
|
304
341
|
}): Promise<string> {
|
|
305
342
|
const { apiKey, baseUrl } = await this.settings.getDecryptedApiKey();
|
|
306
343
|
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
344
|
+
const knowledgeQuery = (input.ragQuery ?? input.userContent).slice(0, 800);
|
|
345
|
+
const knowledgeSnippets = this.knowledge
|
|
346
|
+
? await this.knowledge.fetchContext(knowledgeQuery).catch(() => '')
|
|
347
|
+
: '';
|
|
348
|
+
|
|
349
|
+
const systemContent = [input.systemContent, knowledgeSnippets].filter(Boolean).join('\n\n');
|
|
350
|
+
const reasoningEffort = await this.settings.getReasoningEffort();
|
|
351
|
+
const supportsReasoning = isReasoningEffort(reasoningEffort)
|
|
352
|
+
? await this.reasoningSupportFor(baseUrl, apiKey, input.modelUsed)
|
|
353
|
+
: undefined;
|
|
354
|
+
const payload: Record<string, unknown> = {
|
|
355
|
+
model: input.modelUsed,
|
|
356
|
+
messages: [
|
|
357
|
+
{ role: 'system', content: systemContent },
|
|
358
|
+
{ role: 'user', content: input.userContent },
|
|
359
|
+
],
|
|
360
|
+
temperature: 0.7,
|
|
361
|
+
};
|
|
362
|
+
const attempted = applyReasoningEffort(payload, reasoningEffort, supportsReasoning);
|
|
319
363
|
|
|
320
|
-
|
|
364
|
+
let response = await fetch(`${baseUrl}/chat/completions`, {
|
|
321
365
|
method: 'POST',
|
|
322
366
|
headers: {
|
|
323
367
|
'Content-Type': 'application/json',
|
|
324
368
|
Authorization: `Bearer ${apiKey}`,
|
|
325
369
|
},
|
|
326
|
-
body: JSON.stringify(
|
|
327
|
-
model: input.modelUsed,
|
|
328
|
-
messages: [
|
|
329
|
-
{ role: 'system', content: systemContent },
|
|
330
|
-
{ role: 'user', content: input.userContent },
|
|
331
|
-
],
|
|
332
|
-
temperature: 0.7,
|
|
333
|
-
}),
|
|
370
|
+
body: JSON.stringify(attempted),
|
|
334
371
|
});
|
|
335
372
|
|
|
373
|
+
if (
|
|
374
|
+
!response.ok &&
|
|
375
|
+
attempted.reasoning_effort &&
|
|
376
|
+
(response.status === 400 || response.status === 422)
|
|
377
|
+
) {
|
|
378
|
+
this.reasoningByModel.set(this.supportKey(baseUrl, input.modelUsed), false);
|
|
379
|
+
response = await fetch(`${baseUrl}/chat/completions`, {
|
|
380
|
+
method: 'POST',
|
|
381
|
+
headers: {
|
|
382
|
+
'Content-Type': 'application/json',
|
|
383
|
+
Authorization: `Bearer ${apiKey}`,
|
|
384
|
+
},
|
|
385
|
+
body: JSON.stringify(payload),
|
|
386
|
+
});
|
|
387
|
+
} else if (response.ok && attempted.reasoning_effort) {
|
|
388
|
+
this.reasoningByModel.set(this.supportKey(baseUrl, input.modelUsed), true);
|
|
389
|
+
}
|
|
390
|
+
|
|
336
391
|
if (!response.ok) {
|
|
337
392
|
const errorText = await response.text().catch(() => 'unknown error');
|
|
338
393
|
this.logger.error(`AI provider error ${response.status}: ${errorText}`);
|
|
@@ -352,162 +407,12 @@ export class AiComposeSuggestService {
|
|
|
352
407
|
return draft;
|
|
353
408
|
}
|
|
354
409
|
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
* - 0 bound → ''
|
|
358
|
-
* - 1–2 bound → direct search (no extra LLM round-trip)
|
|
359
|
-
* - 3+ → cheap router LLM picks primary (+ optional followUp), then search
|
|
360
|
-
*/
|
|
361
|
-
private async resolvePokeloSnippets(input: {
|
|
362
|
-
query: string;
|
|
363
|
-
apiKey: string;
|
|
364
|
-
baseUrl: string;
|
|
365
|
-
modelUsed: string;
|
|
366
|
-
}): Promise<string> {
|
|
367
|
-
if (!this.pokeloContext) return '';
|
|
368
|
-
|
|
369
|
-
const bound = (await this.pokeloContext.listBoundProjects?.().catch(() => [])) ?? [];
|
|
370
|
-
if (bound.length === 0) {
|
|
371
|
-
return this.pokeloContext.fetchContext(input.query).catch(() => '');
|
|
372
|
-
}
|
|
373
|
-
|
|
374
|
-
// One or two projects: search them all — router adds latency/cost for little gain
|
|
375
|
-
// and some providers reject the router's stricter completion params (HTTP 400).
|
|
376
|
-
if (bound.length <= 2) {
|
|
377
|
-
return this.pokeloContext.fetchContext(input.query, {
|
|
378
|
-
projectIds: bound.map((p) => p.id),
|
|
379
|
-
});
|
|
380
|
-
}
|
|
381
|
-
|
|
382
|
-
const route = await this.routePokeloProjects({
|
|
383
|
-
query: input.query,
|
|
384
|
-
projects: bound,
|
|
385
|
-
apiKey: input.apiKey,
|
|
386
|
-
baseUrl: input.baseUrl,
|
|
387
|
-
modelUsed: input.modelUsed,
|
|
388
|
-
});
|
|
389
|
-
|
|
390
|
-
const primaryIds = route.primary.length > 0 ? route.primary : [bound[0].id];
|
|
391
|
-
let snippets = await this.pokeloContext.fetchContext(input.query, {
|
|
392
|
-
projectIds: primaryIds,
|
|
393
|
-
});
|
|
394
|
-
|
|
395
|
-
// Second pass: also pull from another brand/project when the router asked for it.
|
|
396
|
-
const followUp = route.followUp.filter((id) => !primaryIds.includes(id));
|
|
397
|
-
if (followUp.length > 0) {
|
|
398
|
-
const more = await this.pokeloContext.fetchContext(input.query, {
|
|
399
|
-
projectIds: followUp,
|
|
400
|
-
});
|
|
401
|
-
if (more) {
|
|
402
|
-
snippets = [snippets, more].filter(Boolean).join('\n\n');
|
|
403
|
-
}
|
|
404
|
-
}
|
|
405
|
-
|
|
406
|
-
return snippets;
|
|
407
|
-
}
|
|
408
|
-
|
|
409
|
-
private async routePokeloProjects(input: {
|
|
410
|
-
query: string;
|
|
411
|
-
projects: Array<{ id: string; name: string }>;
|
|
412
|
-
apiKey: string;
|
|
413
|
-
baseUrl: string;
|
|
414
|
-
modelUsed: string;
|
|
415
|
-
}): Promise<{ primary: string[]; followUp: string[] }> {
|
|
416
|
-
const catalog = input.projects.map((p) => `- ${p.name} (${p.id})`).join('\n');
|
|
417
|
-
|
|
418
|
-
const system = [
|
|
419
|
-
'You route knowledge-base lookups for a CRM AI assistant.',
|
|
420
|
-
'Given a drafting query and available Pokelo projects (brands/products),',
|
|
421
|
-
'choose which projects to search.',
|
|
422
|
-
'Return ONLY compact JSON: {"primary":["uuid",...],"followUp":["uuid",...]}',
|
|
423
|
-
'Rules:',
|
|
424
|
-
'- primary: 1–2 most relevant projects to search first',
|
|
425
|
-
'- followUp: 0–1 extra project if a second brand/product may add useful context',
|
|
426
|
-
'- use only IDs from the catalog',
|
|
427
|
-
'- if unsure, put the broadest/most central project in primary and leave followUp empty',
|
|
428
|
-
].join(' ');
|
|
429
|
-
|
|
430
|
-
const user = [
|
|
431
|
-
'Available projects:',
|
|
432
|
-
catalog,
|
|
433
|
-
'',
|
|
434
|
-
'Drafting query:',
|
|
435
|
-
input.query.slice(0, 800),
|
|
436
|
-
].join('\n');
|
|
437
|
-
|
|
438
|
-
try {
|
|
439
|
-
// Keep the body aligned with completeChat — many OpenAI-compatible providers
|
|
440
|
-
// reject max_tokens and/or temperature: 0 (HTTP 400) while accepting the draft call.
|
|
441
|
-
const response = await fetch(`${input.baseUrl}/chat/completions`, {
|
|
442
|
-
method: 'POST',
|
|
443
|
-
headers: {
|
|
444
|
-
'Content-Type': 'application/json',
|
|
445
|
-
Authorization: `Bearer ${input.apiKey}`,
|
|
446
|
-
},
|
|
447
|
-
body: JSON.stringify({
|
|
448
|
-
model: input.modelUsed,
|
|
449
|
-
messages: [
|
|
450
|
-
{ role: 'system', content: system },
|
|
451
|
-
{ role: 'user', content: user },
|
|
452
|
-
],
|
|
453
|
-
temperature: 0.7,
|
|
454
|
-
}),
|
|
455
|
-
});
|
|
456
|
-
|
|
457
|
-
if (!response.ok) {
|
|
458
|
-
const errText = await response.text().catch(() => '');
|
|
459
|
-
this.logger.warn(
|
|
460
|
-
`Pokelo router HTTP ${response.status} — falling back to all projects: ${errText.slice(0, 300)}`,
|
|
461
|
-
);
|
|
462
|
-
return {
|
|
463
|
-
primary: input.projects.slice(0, 2).map((p) => p.id),
|
|
464
|
-
followUp: input.projects.slice(2, 3).map((p) => p.id),
|
|
465
|
-
};
|
|
466
|
-
}
|
|
467
|
-
|
|
468
|
-
const data = (await response.json()) as {
|
|
469
|
-
choices?: Array<{ message?: { content?: string } }>;
|
|
470
|
-
};
|
|
471
|
-
const raw = data?.choices?.[0]?.message?.content ?? '';
|
|
472
|
-
return parsePokeloRoute(
|
|
473
|
-
raw,
|
|
474
|
-
input.projects.map((p) => p.id),
|
|
475
|
-
);
|
|
476
|
-
} catch (err) {
|
|
477
|
-
this.logger.warn(`Pokelo router failed: ${(err as Error).message}`);
|
|
478
|
-
return {
|
|
479
|
-
primary: input.projects.slice(0, 2).map((p) => p.id),
|
|
480
|
-
followUp: input.projects.slice(2, 3).map((p) => p.id),
|
|
481
|
-
};
|
|
482
|
-
}
|
|
410
|
+
private originKey(baseUrl: string): string {
|
|
411
|
+
return baseUrl.replace(/\/$/, '');
|
|
483
412
|
}
|
|
484
|
-
}
|
|
485
413
|
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
raw: string,
|
|
489
|
-
allowedIds: string[],
|
|
490
|
-
): { primary: string[]; followUp: string[] } {
|
|
491
|
-
const allowed = new Set(allowedIds);
|
|
492
|
-
const empty = { primary: [] as string[], followUp: [] as string[] };
|
|
493
|
-
const jsonMatch = raw.match(/\{[\s\S]*\}/);
|
|
494
|
-
if (!jsonMatch) return empty;
|
|
495
|
-
try {
|
|
496
|
-
const parsed = JSON.parse(jsonMatch[0]) as {
|
|
497
|
-
primary?: unknown;
|
|
498
|
-
followUp?: unknown;
|
|
499
|
-
};
|
|
500
|
-
const pick = (v: unknown, max: number) =>
|
|
501
|
-
(Array.isArray(v) ? v : [])
|
|
502
|
-
.filter((id): id is string => typeof id === 'string' && allowed.has(id))
|
|
503
|
-
.filter((id, i, arr) => arr.indexOf(id) === i)
|
|
504
|
-
.slice(0, max);
|
|
505
|
-
return {
|
|
506
|
-
primary: pick(parsed.primary, 2),
|
|
507
|
-
followUp: pick(parsed.followUp, 1),
|
|
508
|
-
};
|
|
509
|
-
} catch {
|
|
510
|
-
return empty;
|
|
414
|
+
private supportKey(baseUrl: string, model: string): string {
|
|
415
|
+
return `${this.originKey(baseUrl)}\0${model}`;
|
|
511
416
|
}
|
|
512
417
|
}
|
|
513
418
|
|
package/src/ai-compose.module.ts
CHANGED
|
@@ -1,17 +1,26 @@
|
|
|
1
|
-
import { Module } from '@nestjs/common';
|
|
1
|
+
import { Global, Module } from '@nestjs/common';
|
|
2
|
+
import { AI_COMPOSE_LLM } from '../../../packages/plugin-host/src';
|
|
2
3
|
import { AiComposeSettingsService } from './ai-compose-settings.service';
|
|
3
4
|
import { AiComposeSuggestService } from './ai-compose-suggest.service';
|
|
5
|
+
import { AiComposeLlmService } from './ai-compose-llm.service';
|
|
4
6
|
import { AiComposeSettingsController } from './ai-compose-settings.controller';
|
|
5
7
|
import { AiComposeSuggestController } from './ai-compose-suggest.controller';
|
|
6
8
|
import { AiComposeGenerateController } from './ai-compose-generate.controller';
|
|
7
9
|
|
|
8
10
|
/** Host DI (DB_TOKEN, LEADS_SERVICE, MAIL_THREAD_SERVICE, PLUGIN_REGISTRY) comes from global PluginBridgeModule (ADR-0016). */
|
|
11
|
+
@Global()
|
|
9
12
|
@Module({
|
|
10
13
|
controllers: [
|
|
11
14
|
AiComposeSettingsController,
|
|
12
15
|
AiComposeSuggestController,
|
|
13
16
|
AiComposeGenerateController,
|
|
14
17
|
],
|
|
15
|
-
providers: [
|
|
18
|
+
providers: [
|
|
19
|
+
AiComposeSettingsService,
|
|
20
|
+
AiComposeSuggestService,
|
|
21
|
+
AiComposeLlmService,
|
|
22
|
+
{ provide: AI_COMPOSE_LLM, useExisting: AiComposeLlmService },
|
|
23
|
+
],
|
|
24
|
+
exports: [AI_COMPOSE_LLM],
|
|
16
25
|
})
|
|
17
26
|
export class AiComposeModule {}
|
package/src/ai-compose.spec.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { encrypt, decrypt, isAiComposeSecretsKeyConfigured } from './ai-compose-crypto';
|
|
2
2
|
import { AiComposeSettingsService } from './ai-compose-settings.service';
|
|
3
|
+
import { AiComposeLlmService } from './ai-compose-llm.service';
|
|
3
4
|
import {
|
|
4
5
|
AiComposeSuggestService,
|
|
5
6
|
stripCodeFences,
|
|
6
|
-
parsePokeloRoute,
|
|
7
7
|
} from './ai-compose-suggest.service';
|
|
8
8
|
import { AppException } from '../../../packages/plugin-host/src';
|
|
9
9
|
|
|
@@ -15,11 +15,17 @@ describe('ai-compose-crypto', () => {
|
|
|
15
15
|
const HEX_KEY = 'a'.repeat(64);
|
|
16
16
|
|
|
17
17
|
beforeEach(() => {
|
|
18
|
+
delete process.env.KHIRBY_SECRETS_KEY;
|
|
19
|
+
delete process.env.MAIL_SECRETS_KEY;
|
|
20
|
+
delete process.env.POKELO_SECRETS_KEY;
|
|
18
21
|
process.env.AI_COMPOSE_SECRETS_KEY = HEX_KEY;
|
|
19
22
|
});
|
|
20
23
|
|
|
21
24
|
afterEach(() => {
|
|
25
|
+
delete process.env.KHIRBY_SECRETS_KEY;
|
|
26
|
+
delete process.env.MAIL_SECRETS_KEY;
|
|
22
27
|
delete process.env.AI_COMPOSE_SECRETS_KEY;
|
|
28
|
+
delete process.env.POKELO_SECRETS_KEY;
|
|
23
29
|
});
|
|
24
30
|
|
|
25
31
|
it('encrypts and decrypts back to the same plaintext', () => {
|
|
@@ -41,7 +47,7 @@ describe('ai-compose-crypto', () => {
|
|
|
41
47
|
|
|
42
48
|
it('throws on missing key at encrypt time', () => {
|
|
43
49
|
delete process.env.AI_COMPOSE_SECRETS_KEY;
|
|
44
|
-
expect(() => encrypt('anything')).toThrow('
|
|
50
|
+
expect(() => encrypt('anything')).toThrow('KHIRBY_SECRETS_KEY is not set');
|
|
45
51
|
});
|
|
46
52
|
});
|
|
47
53
|
|
|
@@ -117,6 +123,7 @@ describe('AiComposeSettingsService', () => {
|
|
|
117
123
|
const settings = await service.getSettings();
|
|
118
124
|
expect(settings.apiKeyConfigured).toBe(false);
|
|
119
125
|
expect(settings.baseUrl).toBe('https://api.openai.com/v1');
|
|
126
|
+
expect(settings.reasoningEffort).toBe(null);
|
|
120
127
|
});
|
|
121
128
|
|
|
122
129
|
it('getSettings returns apiKeyConfigured: true when row has apiKeyEnc', async () => {
|
|
@@ -154,6 +161,15 @@ describe('AiComposeSettingsService', () => {
|
|
|
154
161
|
const service = new AiComposeSettingsService(db as any, registry as any);
|
|
155
162
|
await expect(service.updateSettings({ baseUrl: 'http://localhost' })).resolves.toBeDefined();
|
|
156
163
|
});
|
|
164
|
+
|
|
165
|
+
it('updateSettings rejects an unknown reasoningEffort', async () => {
|
|
166
|
+
const db = makeMockDb();
|
|
167
|
+
const registry = makeMockRegistry(true);
|
|
168
|
+
const service = new AiComposeSettingsService(db as any, registry as any);
|
|
169
|
+
await expect(service.updateSettings({ reasoningEffort: 'banana' as any })).rejects.toThrow(
|
|
170
|
+
'reasoningEffort',
|
|
171
|
+
);
|
|
172
|
+
});
|
|
157
173
|
});
|
|
158
174
|
|
|
159
175
|
// ──────────────────────────────────────────────────────────────────────────────
|
|
@@ -167,6 +183,7 @@ function makeSettingsService(
|
|
|
167
183
|
allowedModels: string[];
|
|
168
184
|
defaultModel: string | null;
|
|
169
185
|
systemPrompt: string | null;
|
|
186
|
+
reasoningEffort: 'none' | 'low' | 'medium' | 'high' | null;
|
|
170
187
|
}> = {},
|
|
171
188
|
) {
|
|
172
189
|
const cfg = {
|
|
@@ -175,6 +192,7 @@ function makeSettingsService(
|
|
|
175
192
|
allowedModels: ['gpt-4o', 'gpt-3.5-turbo'],
|
|
176
193
|
defaultModel: 'gpt-4o',
|
|
177
194
|
systemPrompt: null,
|
|
195
|
+
reasoningEffort: null as 'none' | 'low' | 'medium' | 'high' | null,
|
|
178
196
|
...overrides,
|
|
179
197
|
};
|
|
180
198
|
|
|
@@ -183,10 +201,40 @@ function makeSettingsService(
|
|
|
183
201
|
getAllowedModels: jest.fn().mockResolvedValue(cfg.allowedModels),
|
|
184
202
|
getDefaultModel: jest.fn().mockResolvedValue(cfg.defaultModel),
|
|
185
203
|
getSystemPrompt: jest.fn().mockResolvedValue(cfg.systemPrompt),
|
|
204
|
+
getReasoningEffort: jest.fn().mockResolvedValue(cfg.reasoningEffort),
|
|
186
205
|
assertPluginEnabled: jest.fn().mockResolvedValue(undefined),
|
|
187
206
|
};
|
|
188
207
|
}
|
|
189
208
|
|
|
209
|
+
function mockProviderFetch(
|
|
210
|
+
opts: {
|
|
211
|
+
models?: Array<Record<string, unknown>>;
|
|
212
|
+
content?: string;
|
|
213
|
+
} = {},
|
|
214
|
+
) {
|
|
215
|
+
(global.fetch as jest.Mock).mockImplementation(async (url: string) => {
|
|
216
|
+
if (String(url).includes('/models')) {
|
|
217
|
+
return {
|
|
218
|
+
ok: true,
|
|
219
|
+
json: async () => ({ data: opts.models ?? [] }),
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
return {
|
|
223
|
+
ok: true,
|
|
224
|
+
json: async () => ({
|
|
225
|
+
choices: [{ message: { content: opts.content ?? 'ok' } }],
|
|
226
|
+
}),
|
|
227
|
+
};
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function chatBodyFromFetch(index = 0): Record<string, unknown> {
|
|
232
|
+
const calls = (global.fetch as jest.Mock).mock.calls.filter((c: [string]) =>
|
|
233
|
+
String(c[0]).includes('/chat/completions'),
|
|
234
|
+
);
|
|
235
|
+
return JSON.parse(calls[index][1].body);
|
|
236
|
+
}
|
|
237
|
+
|
|
190
238
|
const MOCK_THREAD = {
|
|
191
239
|
id: 'thread-1',
|
|
192
240
|
subject: 'Hello',
|
|
@@ -254,6 +302,7 @@ describe('AiComposeSuggestService', () => {
|
|
|
254
302
|
settings as any,
|
|
255
303
|
mockMailThreads as any,
|
|
256
304
|
mockLeads as any,
|
|
305
|
+
null,
|
|
257
306
|
);
|
|
258
307
|
|
|
259
308
|
const result = await service.suggest({ threadId: 'thread-1', leadId: 'l-1' });
|
|
@@ -265,12 +314,121 @@ describe('AiComposeSuggestService', () => {
|
|
|
265
314
|
);
|
|
266
315
|
});
|
|
267
316
|
|
|
317
|
+
it('sends reasoning_effort when the catalog advertises it', async () => {
|
|
318
|
+
mockProviderFetch({
|
|
319
|
+
models: [
|
|
320
|
+
{
|
|
321
|
+
id: 'reasoner',
|
|
322
|
+
object: 'model',
|
|
323
|
+
supported_parameters: ['max_tokens', 'reasoning_effort'],
|
|
324
|
+
},
|
|
325
|
+
],
|
|
326
|
+
});
|
|
327
|
+
const settings = makeSettingsService({
|
|
328
|
+
defaultModel: 'reasoner',
|
|
329
|
+
allowedModels: ['reasoner'],
|
|
330
|
+
reasoningEffort: 'high',
|
|
331
|
+
});
|
|
332
|
+
const service = new AiComposeSuggestService(
|
|
333
|
+
settings as any,
|
|
334
|
+
mockMailThreads as any,
|
|
335
|
+
mockLeads as any,
|
|
336
|
+
null,
|
|
337
|
+
);
|
|
338
|
+
await service.suggest({ threadId: 'thread-1' });
|
|
339
|
+
const body = chatBodyFromFetch();
|
|
340
|
+
expect(body.reasoning_effort).toBe('high');
|
|
341
|
+
expect(body.temperature).toBeUndefined();
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
it('does not send reasoning_effort when the catalog lists params without it', async () => {
|
|
345
|
+
mockProviderFetch({
|
|
346
|
+
models: [
|
|
347
|
+
{
|
|
348
|
+
id: 'gpt-4o',
|
|
349
|
+
object: 'model',
|
|
350
|
+
supported_parameters: ['temperature', 'max_tokens'],
|
|
351
|
+
},
|
|
352
|
+
],
|
|
353
|
+
});
|
|
354
|
+
const settings = makeSettingsService({ reasoningEffort: 'high' });
|
|
355
|
+
const service = new AiComposeSuggestService(
|
|
356
|
+
settings as any,
|
|
357
|
+
mockMailThreads as any,
|
|
358
|
+
mockLeads as any,
|
|
359
|
+
null,
|
|
360
|
+
);
|
|
361
|
+
await service.suggest({ threadId: 'thread-1' });
|
|
362
|
+
const body = chatBodyFromFetch();
|
|
363
|
+
expect(body.reasoning_effort).toBeUndefined();
|
|
364
|
+
expect(body.temperature).toBe(0.7);
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
it('tries reasoning_effort when the catalog is silent, then drops it after HTTP 400', async () => {
|
|
368
|
+
let completions = 0;
|
|
369
|
+
(global.fetch as jest.Mock).mockImplementation(async (url: string) => {
|
|
370
|
+
if (String(url).includes('/models')) {
|
|
371
|
+
return {
|
|
372
|
+
ok: true,
|
|
373
|
+
json: async () => ({ data: [{ id: 'gpt-4o', object: 'model' }] }),
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
completions += 1;
|
|
377
|
+
if (completions === 1) {
|
|
378
|
+
return { ok: false, status: 400, text: async () => 'unknown parameter' };
|
|
379
|
+
}
|
|
380
|
+
return {
|
|
381
|
+
ok: true,
|
|
382
|
+
json: async () => ({ choices: [{ message: { content: 'ok' } }] }),
|
|
383
|
+
};
|
|
384
|
+
});
|
|
385
|
+
const settings = makeSettingsService({ reasoningEffort: 'high' });
|
|
386
|
+
const service = new AiComposeSuggestService(
|
|
387
|
+
settings as any,
|
|
388
|
+
mockMailThreads as any,
|
|
389
|
+
mockLeads as any,
|
|
390
|
+
null,
|
|
391
|
+
);
|
|
392
|
+
await service.suggest({ threadId: 'thread-1' });
|
|
393
|
+
expect(completions).toBe(2);
|
|
394
|
+
expect(chatBodyFromFetch(0).reasoning_effort).toBe('high');
|
|
395
|
+
expect(chatBodyFromFetch(1).reasoning_effort).toBeUndefined();
|
|
396
|
+
|
|
397
|
+
await service.suggest({ threadId: 'thread-1' });
|
|
398
|
+
expect(completions).toBe(3);
|
|
399
|
+
expect(chatBodyFromFetch(2).reasoning_effort).toBeUndefined();
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
it('exposes catalog reasoning flags from fetchModels', async () => {
|
|
403
|
+
mockProviderFetch({
|
|
404
|
+
models: [
|
|
405
|
+
{ id: 'a', object: 'model', supported_parameters: ['reasoning'] },
|
|
406
|
+
{ id: 'b', object: 'model', supported_parameters: ['temperature'] },
|
|
407
|
+
{ id: 'c', object: 'model' },
|
|
408
|
+
],
|
|
409
|
+
});
|
|
410
|
+
const service = new AiComposeSuggestService(
|
|
411
|
+
makeSettingsService() as any,
|
|
412
|
+
mockMailThreads as any,
|
|
413
|
+
mockLeads as any,
|
|
414
|
+
null,
|
|
415
|
+
);
|
|
416
|
+
await expect(
|
|
417
|
+
service.fetchModels('https://api.openai.com/v1', 'sk-test'),
|
|
418
|
+
).resolves.toEqual([
|
|
419
|
+
{ id: 'a', label: 'a', supportsReasoning: true },
|
|
420
|
+
{ id: 'b', label: 'b', supportsReasoning: false },
|
|
421
|
+
{ id: 'c', label: 'c', supportsReasoning: null },
|
|
422
|
+
]);
|
|
423
|
+
});
|
|
424
|
+
|
|
268
425
|
it('throws 400 when model not in allowlist', async () => {
|
|
269
426
|
const settings = makeSettingsService({ allowedModels: ['gpt-4o'] });
|
|
270
427
|
const service = new AiComposeSuggestService(
|
|
271
428
|
settings as any,
|
|
272
429
|
mockMailThreads as any,
|
|
273
430
|
mockLeads as any,
|
|
431
|
+
null,
|
|
274
432
|
);
|
|
275
433
|
|
|
276
434
|
await expect(service.suggest({ threadId: 'thread-1', model: 'claude-3' })).rejects.toThrow();
|
|
@@ -287,6 +445,7 @@ describe('AiComposeSuggestService', () => {
|
|
|
287
445
|
settings as any,
|
|
288
446
|
mockMailThreads as any,
|
|
289
447
|
mockLeads as any,
|
|
448
|
+
null,
|
|
290
449
|
);
|
|
291
450
|
|
|
292
451
|
await expect(service.suggest({ threadId: 'thread-1' })).rejects.toThrow(
|
|
@@ -306,6 +465,7 @@ describe('AiComposeSuggestService', () => {
|
|
|
306
465
|
settings as any,
|
|
307
466
|
mockMailThreads as any,
|
|
308
467
|
mockLeads as any,
|
|
468
|
+
null,
|
|
309
469
|
);
|
|
310
470
|
|
|
311
471
|
await expect(service.suggest({ threadId: 'thread-1' })).rejects.toThrow();
|
|
@@ -322,6 +482,7 @@ describe('AiComposeSuggestService', () => {
|
|
|
322
482
|
settings as any,
|
|
323
483
|
mockMailThreads as any,
|
|
324
484
|
mockLeads as any,
|
|
485
|
+
null,
|
|
325
486
|
);
|
|
326
487
|
|
|
327
488
|
await service.suggest({ threadId: 'thread-1', instruction: 'Be formal' });
|
|
@@ -345,6 +506,7 @@ describe('AiComposeSuggestService', () => {
|
|
|
345
506
|
settings as any,
|
|
346
507
|
mockMailThreads as any,
|
|
347
508
|
mockLeads as any,
|
|
509
|
+
null,
|
|
348
510
|
);
|
|
349
511
|
|
|
350
512
|
const result = await service.suggest({ leadId: 'l-1' });
|
|
@@ -368,141 +530,40 @@ describe('AiComposeSuggestService', () => {
|
|
|
368
530
|
settings as any,
|
|
369
531
|
mockMailThreads as any,
|
|
370
532
|
mockLeads as any,
|
|
533
|
+
null,
|
|
371
534
|
);
|
|
372
535
|
|
|
373
536
|
await expect(service.suggest({})).rejects.toThrow('Either threadId or leadId is required');
|
|
374
537
|
});
|
|
375
538
|
|
|
376
|
-
it('appends
|
|
539
|
+
it('appends knowledge snippets to the system message when a provider is present', async () => {
|
|
377
540
|
(global.fetch as jest.Mock).mockResolvedValue({
|
|
378
541
|
ok: true,
|
|
379
542
|
json: async () => ({ choices: [{ message: { content: 'Draft' } }] }),
|
|
380
543
|
});
|
|
381
544
|
|
|
382
|
-
const
|
|
383
|
-
fetchContext: jest.fn().mockResolvedValue('
|
|
384
|
-
listBoundProjects: jest.fn().mockResolvedValue([{ id: 'p1', name: 'CRM' }]),
|
|
545
|
+
const knowledge = {
|
|
546
|
+
fetchContext: jest.fn().mockResolvedValue('Pricing is X'),
|
|
385
547
|
};
|
|
386
548
|
const settings = makeSettingsService();
|
|
387
549
|
const service = new AiComposeSuggestService(
|
|
388
550
|
settings as any,
|
|
389
551
|
mockMailThreads as any,
|
|
390
552
|
mockLeads as any,
|
|
391
|
-
|
|
553
|
+
knowledge as any,
|
|
392
554
|
);
|
|
393
555
|
|
|
394
556
|
await service.suggest({ threadId: 'thread-1', leadId: 'l-1', instruction: 'Be brief' });
|
|
395
557
|
|
|
396
|
-
expect(
|
|
397
|
-
|
|
398
|
-
|
|
558
|
+
expect(knowledge.fetchContext).toHaveBeenCalledTimes(1);
|
|
559
|
+
expect(knowledge.fetchContext).toHaveBeenCalledWith(expect.any(String));
|
|
560
|
+
expect(knowledge.fetchContext.mock.calls[0][1]).toBeUndefined();
|
|
399
561
|
const body = JSON.parse((global.fetch as jest.Mock).mock.calls[0][1].body);
|
|
400
562
|
const systemMsg = body.messages.find((m: { role: string }) => m.role === 'system');
|
|
401
|
-
expect(systemMsg.content).toContain('Kontekst z Pokelo');
|
|
402
563
|
expect(systemMsg.content).toContain('Pricing is X');
|
|
403
564
|
});
|
|
404
565
|
|
|
405
|
-
it('
|
|
406
|
-
(global.fetch as jest.Mock).mockImplementation(async (_url: string, init: { body: string }) => {
|
|
407
|
-
const body = JSON.parse(init.body);
|
|
408
|
-
const system = body.messages?.[0]?.content ?? '';
|
|
409
|
-
if (typeof system === 'string' && system.includes('route knowledge-base')) {
|
|
410
|
-
// Router payload must match draft call (no max_tokens / no temperature:0)
|
|
411
|
-
expect(body.max_tokens).toBeUndefined();
|
|
412
|
-
expect(body.temperature).toBe(0.7);
|
|
413
|
-
return {
|
|
414
|
-
ok: true,
|
|
415
|
-
json: async () => ({
|
|
416
|
-
choices: [
|
|
417
|
-
{
|
|
418
|
-
message: {
|
|
419
|
-
content: JSON.stringify({
|
|
420
|
-
primary: ['crm'],
|
|
421
|
-
followUp: ['finsly'],
|
|
422
|
-
}),
|
|
423
|
-
},
|
|
424
|
-
},
|
|
425
|
-
],
|
|
426
|
-
}),
|
|
427
|
-
};
|
|
428
|
-
}
|
|
429
|
-
return {
|
|
430
|
-
ok: true,
|
|
431
|
-
json: async () => ({ choices: [{ message: { content: 'Draft' } }] }),
|
|
432
|
-
};
|
|
433
|
-
});
|
|
434
|
-
|
|
435
|
-
const pokelo = {
|
|
436
|
-
listBoundProjects: jest.fn().mockResolvedValue([
|
|
437
|
-
{ id: 'crm', name: 'Bearly CRM' },
|
|
438
|
-
{ id: 'finsly', name: 'Finsly' },
|
|
439
|
-
{ id: 'pokelo', name: 'Pokelo' },
|
|
440
|
-
]),
|
|
441
|
-
fetchContext: jest
|
|
442
|
-
.fn()
|
|
443
|
-
.mockResolvedValueOnce('--- Kontekst z Pokelo ---\n[Bearly CRM] CRM facts')
|
|
444
|
-
.mockResolvedValueOnce('--- Kontekst z Pokelo ---\n[Finsly] Billing facts'),
|
|
445
|
-
};
|
|
446
|
-
|
|
447
|
-
const service = new AiComposeSuggestService(
|
|
448
|
-
makeSettingsService() as any,
|
|
449
|
-
mockMailThreads as any,
|
|
450
|
-
mockLeads as any,
|
|
451
|
-
pokelo as any,
|
|
452
|
-
);
|
|
453
|
-
|
|
454
|
-
await service.suggest({ threadId: 'thread-1', instruction: 'Mention Finsly pricing' });
|
|
455
|
-
|
|
456
|
-
expect(pokelo.fetchContext).toHaveBeenNthCalledWith(1, expect.any(String), {
|
|
457
|
-
projectIds: ['crm'],
|
|
458
|
-
});
|
|
459
|
-
expect(pokelo.fetchContext).toHaveBeenNthCalledWith(2, expect.any(String), {
|
|
460
|
-
projectIds: ['finsly'],
|
|
461
|
-
});
|
|
462
|
-
|
|
463
|
-
const composeCall = (global.fetch as jest.Mock).mock.calls.find((c) => {
|
|
464
|
-
const body = JSON.parse(c[1].body);
|
|
465
|
-
return !String(body.messages?.[0]?.content ?? '').includes('route knowledge-base');
|
|
466
|
-
});
|
|
467
|
-
const systemMsg = JSON.parse(composeCall[1].body).messages.find(
|
|
468
|
-
(m: { role: string }) => m.role === 'system',
|
|
469
|
-
);
|
|
470
|
-
expect(systemMsg.content).toContain('CRM facts');
|
|
471
|
-
expect(systemMsg.content).toContain('Billing facts');
|
|
472
|
-
});
|
|
473
|
-
|
|
474
|
-
it('searches both projects directly when exactly two are bound (no router)', async () => {
|
|
475
|
-
(global.fetch as jest.Mock).mockResolvedValue({
|
|
476
|
-
ok: true,
|
|
477
|
-
json: async () => ({ choices: [{ message: { content: 'Draft' } }] }),
|
|
478
|
-
});
|
|
479
|
-
|
|
480
|
-
const pokelo = {
|
|
481
|
-
listBoundProjects: jest.fn().mockResolvedValue([
|
|
482
|
-
{ id: 'crm', name: 'Bearly CRM' },
|
|
483
|
-
{ id: 'finsly', name: 'Finsly' },
|
|
484
|
-
]),
|
|
485
|
-
fetchContext: jest.fn().mockResolvedValue('--- Kontekst z Pokelo ---\n[CRM] a\n[Finsly] b'),
|
|
486
|
-
};
|
|
487
|
-
|
|
488
|
-
const service = new AiComposeSuggestService(
|
|
489
|
-
makeSettingsService() as any,
|
|
490
|
-
mockMailThreads as any,
|
|
491
|
-
mockLeads as any,
|
|
492
|
-
pokelo as any,
|
|
493
|
-
);
|
|
494
|
-
|
|
495
|
-
await service.suggest({ threadId: 'thread-1', instruction: 'Hello' });
|
|
496
|
-
|
|
497
|
-
expect(pokelo.fetchContext).toHaveBeenCalledTimes(1);
|
|
498
|
-
expect(pokelo.fetchContext).toHaveBeenCalledWith(expect.any(String), {
|
|
499
|
-
projectIds: ['crm', 'finsly'],
|
|
500
|
-
});
|
|
501
|
-
// Only the compose completion — no router call
|
|
502
|
-
expect(global.fetch).toHaveBeenCalledTimes(1);
|
|
503
|
-
});
|
|
504
|
-
|
|
505
|
-
it('works without Pokelo when context service is null', async () => {
|
|
566
|
+
it('works without a knowledge provider', async () => {
|
|
506
567
|
(global.fetch as jest.Mock).mockResolvedValue({
|
|
507
568
|
ok: true,
|
|
508
569
|
json: async () => ({ choices: [{ message: { content: 'Draft' } }] }),
|
|
@@ -519,7 +580,7 @@ describe('AiComposeSuggestService', () => {
|
|
|
519
580
|
await service.suggest({ threadId: 'thread-1' });
|
|
520
581
|
const body = JSON.parse((global.fetch as jest.Mock).mock.calls[0][1].body);
|
|
521
582
|
const systemMsg = body.messages.find((m: { role: string }) => m.role === 'system');
|
|
522
|
-
expect(systemMsg.content).not.toContain('
|
|
583
|
+
expect(systemMsg.content).not.toContain('Pricing is X');
|
|
523
584
|
});
|
|
524
585
|
});
|
|
525
586
|
|
|
@@ -537,7 +598,12 @@ describe('AiComposeSuggestService.generateNewsletter', () => {
|
|
|
537
598
|
});
|
|
538
599
|
|
|
539
600
|
function service(settings = makeSettingsService()) {
|
|
540
|
-
return new AiComposeSuggestService(
|
|
601
|
+
return new AiComposeSuggestService(
|
|
602
|
+
settings as any,
|
|
603
|
+
mockMailThreads as any,
|
|
604
|
+
mockLeads as any,
|
|
605
|
+
null,
|
|
606
|
+
);
|
|
541
607
|
}
|
|
542
608
|
|
|
543
609
|
it('asks the model for HTML and strips fences', async () => {
|
|
@@ -625,21 +691,20 @@ describe('AiComposeSuggestService.generateNewsletter', () => {
|
|
|
625
691
|
expect(userMsg.content).toContain('## Old draft');
|
|
626
692
|
});
|
|
627
693
|
|
|
628
|
-
it('appends
|
|
694
|
+
it('appends knowledge snippets for newsletter generate', async () => {
|
|
629
695
|
(global.fetch as jest.Mock).mockResolvedValue({
|
|
630
696
|
ok: true,
|
|
631
697
|
json: async () => ({ choices: [{ message: { content: '<p>Hi</p>' } }] }),
|
|
632
698
|
});
|
|
633
699
|
|
|
634
|
-
const
|
|
635
|
-
fetchContext: jest.fn().mockResolvedValue('
|
|
636
|
-
listBoundProjects: jest.fn().mockResolvedValue([{ id: 'p1', name: 'CRM' }]),
|
|
700
|
+
const knowledge = {
|
|
701
|
+
fetchContext: jest.fn().mockResolvedValue('Brand voice: warm'),
|
|
637
702
|
};
|
|
638
703
|
const svc = new AiComposeSuggestService(
|
|
639
704
|
makeSettingsService() as any,
|
|
640
705
|
mockMailThreads as any,
|
|
641
706
|
mockLeads as any,
|
|
642
|
-
|
|
707
|
+
knowledge as any,
|
|
643
708
|
);
|
|
644
709
|
|
|
645
710
|
await svc.generateNewsletter({
|
|
@@ -649,37 +714,13 @@ describe('AiComposeSuggestService.generateNewsletter', () => {
|
|
|
649
714
|
instruction: 'Product news',
|
|
650
715
|
});
|
|
651
716
|
|
|
652
|
-
expect(
|
|
653
|
-
projectIds: ['p1'],
|
|
654
|
-
});
|
|
717
|
+
expect(knowledge.fetchContext).toHaveBeenCalledWith(expect.stringContaining('Product news'));
|
|
655
718
|
const body = JSON.parse((global.fetch as jest.Mock).mock.calls[0][1].body);
|
|
656
719
|
const systemMsg = body.messages.find((m: { role: string }) => m.role === 'system');
|
|
657
720
|
expect(systemMsg.content).toContain('Brand voice: warm');
|
|
658
721
|
});
|
|
659
722
|
});
|
|
660
723
|
|
|
661
|
-
describe('parsePokeloRoute', () => {
|
|
662
|
-
it('extracts primary and followUp IDs from JSON', () => {
|
|
663
|
-
const route = parsePokeloRoute('Here you go:\n{"primary":["a","b"],"followUp":["c"]}\n', [
|
|
664
|
-
'a',
|
|
665
|
-
'b',
|
|
666
|
-
'c',
|
|
667
|
-
'd',
|
|
668
|
-
]);
|
|
669
|
-
expect(route).toEqual({ primary: ['a', 'b'], followUp: ['c'] });
|
|
670
|
-
});
|
|
671
|
-
|
|
672
|
-
it('drops unknown IDs and caps lengths', () => {
|
|
673
|
-
const route = parsePokeloRoute('{"primary":["a","b","x","y"],"followUp":["c","d"]}', [
|
|
674
|
-
'a',
|
|
675
|
-
'b',
|
|
676
|
-
'c',
|
|
677
|
-
]);
|
|
678
|
-
expect(route.primary).toEqual(['a', 'b']);
|
|
679
|
-
expect(route.followUp).toEqual(['c']);
|
|
680
|
-
});
|
|
681
|
-
});
|
|
682
|
-
|
|
683
724
|
describe('stripCodeFences', () => {
|
|
684
725
|
it('unwraps fenced blocks', () => {
|
|
685
726
|
expect(stripCodeFences('```md\n# Hi\n```')).toBe('# Hi');
|
|
@@ -689,3 +730,50 @@ describe('stripCodeFences', () => {
|
|
|
689
730
|
expect(stripCodeFences('Just text')).toBe('Just text');
|
|
690
731
|
});
|
|
691
732
|
});
|
|
733
|
+
|
|
734
|
+
describe('AiComposeLlmService', () => {
|
|
735
|
+
it('throws when no default model is set', async () => {
|
|
736
|
+
const settings = {
|
|
737
|
+
assertPluginEnabled: jest.fn().mockResolvedValue(undefined),
|
|
738
|
+
getDecryptedApiKey: jest.fn().mockResolvedValue({
|
|
739
|
+
apiKey: 'sk-test',
|
|
740
|
+
baseUrl: 'https://api.openai.com/v1',
|
|
741
|
+
}),
|
|
742
|
+
getDefaultModel: jest.fn().mockResolvedValue(null),
|
|
743
|
+
getReasoningEffort: jest.fn().mockResolvedValue(null),
|
|
744
|
+
} as unknown as AiComposeSettingsService;
|
|
745
|
+
|
|
746
|
+
const svc = new AiComposeLlmService(settings, { reasoningSupportFor: jest.fn() } as any);
|
|
747
|
+
await expect(svc.getCompletionConfig()).rejects.toThrow(/No default model configured/);
|
|
748
|
+
});
|
|
749
|
+
|
|
750
|
+
it('returns BYOK config when settings are complete', async () => {
|
|
751
|
+
const settings = {
|
|
752
|
+
assertPluginEnabled: jest.fn().mockResolvedValue(undefined),
|
|
753
|
+
getDecryptedApiKey: jest.fn().mockResolvedValue({
|
|
754
|
+
apiKey: 'sk-test',
|
|
755
|
+
baseUrl: 'https://api.openai.com/v1',
|
|
756
|
+
}),
|
|
757
|
+
getDefaultModel: jest.fn().mockResolvedValue('gpt-4o-mini'),
|
|
758
|
+
getReasoningEffort: jest.fn().mockResolvedValue('medium'),
|
|
759
|
+
} as unknown as AiComposeSettingsService;
|
|
760
|
+
const suggest = {
|
|
761
|
+
cachedReasoningSupport: jest.fn().mockReturnValue(true),
|
|
762
|
+
reasoningSupportFor: jest.fn(),
|
|
763
|
+
};
|
|
764
|
+
|
|
765
|
+
const svc = new AiComposeLlmService(settings, suggest as any);
|
|
766
|
+
await expect(svc.getCompletionConfig()).resolves.toEqual({
|
|
767
|
+
apiKey: 'sk-test',
|
|
768
|
+
baseUrl: 'https://api.openai.com/v1',
|
|
769
|
+
model: 'gpt-4o-mini',
|
|
770
|
+
reasoningEffort: 'medium',
|
|
771
|
+
reasoningSupported: true,
|
|
772
|
+
});
|
|
773
|
+
expect(suggest.cachedReasoningSupport).toHaveBeenCalledWith(
|
|
774
|
+
'https://api.openai.com/v1',
|
|
775
|
+
'gpt-4o-mini',
|
|
776
|
+
);
|
|
777
|
+
expect(suggest.reasoningSupportFor).not.toHaveBeenCalled();
|
|
778
|
+
});
|
|
779
|
+
});
|
package/src/migrations.ts
CHANGED
|
@@ -12,5 +12,7 @@ CREATE TABLE IF NOT EXISTS ai_compose_settings (
|
|
|
12
12
|
system_prompt TEXT,
|
|
13
13
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
14
14
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
15
|
-
)
|
|
15
|
+
);
|
|
16
|
+
|
|
17
|
+
ALTER TABLE ai_compose_settings ADD COLUMN IF NOT EXISTS reasoning_effort TEXT
|
|
16
18
|
`;
|
package/src/schema.ts
CHANGED
|
@@ -7,6 +7,7 @@ export const aiComposeSettings = pgTable('ai_compose_settings', {
|
|
|
7
7
|
defaultModel: text('default_model'),
|
|
8
8
|
allowedModels: text('allowed_models').array().notNull().default([]),
|
|
9
9
|
systemPrompt: text('system_prompt'),
|
|
10
|
+
reasoningEffort: text('reasoning_effort'),
|
|
10
11
|
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
|
11
12
|
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
|
12
13
|
});
|
package/LICENSE
DELETED
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
MIT License
|
|
2
|
-
|
|
3
|
-
Copyright (c) 2026 Khirby Labs
|
|
4
|
-
|
|
5
|
-
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
-
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
-
in the Software without restriction, including without limitation the rights
|
|
8
|
-
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
-
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
-
furnished to do so, subject to the following conditions:
|
|
11
|
-
|
|
12
|
-
The above copyright notice and this permission notice shall be included in all
|
|
13
|
-
copies or substantial portions of the Software.
|
|
14
|
-
|
|
15
|
-
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
-
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
-
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
-
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
-
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
-
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
-
SOFTWARE.
|