@khirby/plugin-ai-compose 1.1.0 → 1.3.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 -1
- package/src/ai-compose-crypto.ts +5 -60
- package/src/ai-compose-llm.service.ts +40 -11
- 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 +96 -185
- package/src/ai-compose.spec.ts +209 -149
- package/src/migrations.ts +3 -1
- package/src/schema.ts +1 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@khirby/plugin-ai-compose",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.0",
|
|
4
4
|
"description": "Khirby — AI-powered reply draft suggestions (BYOK, OpenAI-compatible)",
|
|
5
5
|
"main": "src/index.ts",
|
|
6
6
|
"keywords": [
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
"@khirby/plugin-host": "^1.0.0",
|
|
11
11
|
"@khirby/plugin-sdk": "^1.0.0",
|
|
12
12
|
"@nestjs/common": "*",
|
|
13
|
+
"@nestjs/core": "*",
|
|
13
14
|
"@nestjs/swagger": "*",
|
|
14
15
|
"drizzle-orm": "*"
|
|
15
16
|
},
|
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';
|
|
@@ -1,25 +1,54 @@
|
|
|
1
|
-
import { Injectable } from '@nestjs/common';
|
|
2
|
-
import
|
|
1
|
+
import { Injectable, Logger } from '@nestjs/common';
|
|
2
|
+
import {
|
|
3
|
+
AppException,
|
|
4
|
+
isReasoningEffort,
|
|
5
|
+
type AiComposeLlmLike,
|
|
6
|
+
} from '../../../packages/plugin-host/src';
|
|
3
7
|
import { AiComposeSettingsService } from './ai-compose-settings.service';
|
|
8
|
+
import { AiComposeSuggestService } from './ai-compose-suggest.service';
|
|
4
9
|
|
|
5
10
|
/** Host token surface for Ask Khirby agent chat (ADR-0040). */
|
|
6
11
|
@Injectable()
|
|
7
12
|
export class AiComposeLlmService implements AiComposeLlmLike {
|
|
8
|
-
|
|
13
|
+
private readonly logger = new Logger(AiComposeLlmService.name);
|
|
14
|
+
|
|
15
|
+
constructor(
|
|
16
|
+
private readonly settings: AiComposeSettingsService,
|
|
17
|
+
private readonly suggest: AiComposeSuggestService,
|
|
18
|
+
) {}
|
|
9
19
|
|
|
10
20
|
async getCompletionConfig(): Promise<{
|
|
11
21
|
baseUrl: string;
|
|
12
22
|
apiKey: string;
|
|
13
23
|
model: string;
|
|
24
|
+
reasoningEffort?: 'none' | 'low' | 'medium' | 'high' | null;
|
|
25
|
+
reasoningSupported?: boolean | null;
|
|
14
26
|
} | null> {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
+
}
|
|
23
45
|
}
|
|
46
|
+
return {
|
|
47
|
+
baseUrl,
|
|
48
|
+
apiKey,
|
|
49
|
+
model: model.trim(),
|
|
50
|
+
reasoningEffort,
|
|
51
|
+
reasoningSupported,
|
|
52
|
+
};
|
|
24
53
|
}
|
|
25
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
|
}
|
|
@@ -1,11 +1,16 @@
|
|
|
1
|
-
import { Injectable, Inject, Logger
|
|
1
|
+
import { Injectable, Inject, Logger } from '@nestjs/common';
|
|
2
|
+
import { ModuleRef } from '@nestjs/core';
|
|
2
3
|
import {
|
|
3
4
|
LEADS_SERVICE,
|
|
4
5
|
MAIL_THREAD_SERVICE,
|
|
5
|
-
|
|
6
|
+
KNOWLEDGE_CONTEXT,
|
|
6
7
|
type MailThreadServiceLike,
|
|
7
|
-
type
|
|
8
|
+
type KnowledgeContextLike,
|
|
8
9
|
AppException,
|
|
10
|
+
applyReasoningEffort,
|
|
11
|
+
isReasoningEffort,
|
|
12
|
+
parseModelReasoningSupport,
|
|
13
|
+
resolveLoadedProvider,
|
|
9
14
|
} from '../../../packages/plugin-host/src';
|
|
10
15
|
import { AiComposeSettingsService } from './ai-compose-settings.service';
|
|
11
16
|
|
|
@@ -33,16 +38,22 @@ export type LeadsServiceLike = {
|
|
|
33
38
|
@Injectable()
|
|
34
39
|
export class AiComposeSuggestService {
|
|
35
40
|
private readonly logger = new Logger(AiComposeSuggestService.name);
|
|
41
|
+
/** Advertised (or learned from a 400) reasoning support, keyed by baseUrl + model id. */
|
|
42
|
+
private readonly reasoningByModel = new Map<string, boolean>();
|
|
43
|
+
private readonly catalogFetched = new Set<string>();
|
|
36
44
|
|
|
37
45
|
constructor(
|
|
38
46
|
private readonly settings: AiComposeSettingsService,
|
|
39
47
|
@Inject(MAIL_THREAD_SERVICE) private readonly mailThreads: MailThreadServiceLike,
|
|
40
48
|
@Inject(LEADS_SERVICE) private readonly leads: LeadsServiceLike,
|
|
41
|
-
|
|
42
|
-
@Inject(POKELO_CONTEXT_SERVICE)
|
|
43
|
-
private readonly pokeloContext: PokeloContextServiceLike | null = null,
|
|
49
|
+
private readonly moduleRef: ModuleRef,
|
|
44
50
|
) {}
|
|
45
51
|
|
|
52
|
+
/** Volume Pokelo may bind after this service is constructed (ADR-0048 / ADR-0050). */
|
|
53
|
+
private knowledge(): KnowledgeContextLike | null {
|
|
54
|
+
return resolveLoadedProvider<KnowledgeContextLike>(this.moduleRef, KNOWLEDGE_CONTEXT);
|
|
55
|
+
}
|
|
56
|
+
|
|
46
57
|
async availability(): Promise<{ available: boolean; defaultModel: string | null }> {
|
|
47
58
|
const defaultModel = await this.settings.getDefaultModel();
|
|
48
59
|
try {
|
|
@@ -218,7 +229,10 @@ export class AiComposeSuggestService {
|
|
|
218
229
|
return { draft, modelUsed };
|
|
219
230
|
}
|
|
220
231
|
|
|
221
|
-
async fetchModels(
|
|
232
|
+
async fetchModels(
|
|
233
|
+
baseUrl: string,
|
|
234
|
+
apiKey: string,
|
|
235
|
+
): Promise<{ id: string; label: string; supportsReasoning: boolean | null }[]> {
|
|
222
236
|
const response = await fetch(`${baseUrl}/models`, {
|
|
223
237
|
headers: { Authorization: `Bearer ${apiKey}` },
|
|
224
238
|
});
|
|
@@ -231,12 +245,40 @@ export class AiComposeSuggestService {
|
|
|
231
245
|
}
|
|
232
246
|
|
|
233
247
|
const data = (await response.json()) as {
|
|
234
|
-
data?: Array<{ id
|
|
248
|
+
data?: Array<Record<string, unknown> & { id?: string; object?: string }>;
|
|
235
249
|
};
|
|
236
250
|
|
|
251
|
+
this.catalogFetched.add(this.originKey(baseUrl));
|
|
252
|
+
|
|
237
253
|
return (data?.data ?? [])
|
|
238
|
-
.filter((m) => m.object === 'model' || !m.object)
|
|
239
|
-
.map((m) =>
|
|
254
|
+
.filter((m) => typeof m.id === 'string' && (m.object === 'model' || !m.object))
|
|
255
|
+
.map((m) => {
|
|
256
|
+
const id = String(m.id);
|
|
257
|
+
const support = parseModelReasoningSupport(m);
|
|
258
|
+
if (support !== undefined) {
|
|
259
|
+
this.reasoningByModel.set(this.supportKey(baseUrl, id), support);
|
|
260
|
+
}
|
|
261
|
+
return { id, label: id, supportsReasoning: support ?? null };
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
async reasoningSupportFor(
|
|
266
|
+
baseUrl: string,
|
|
267
|
+
apiKey: string,
|
|
268
|
+
model: string,
|
|
269
|
+
): Promise<boolean | undefined> {
|
|
270
|
+
const key = this.supportKey(baseUrl, model);
|
|
271
|
+
const cached = this.reasoningByModel.get(key);
|
|
272
|
+
if (cached !== undefined) return cached;
|
|
273
|
+
if (!this.catalogFetched.has(this.originKey(baseUrl))) {
|
|
274
|
+
await this.fetchModels(baseUrl, apiKey).catch(() => undefined);
|
|
275
|
+
}
|
|
276
|
+
return this.reasoningByModel.get(key);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** In-memory only — Ask Khirby must not block SSE on GET /models. */
|
|
280
|
+
cachedReasoningSupport(baseUrl: string, model: string): boolean | undefined {
|
|
281
|
+
return this.reasoningByModel.get(this.supportKey(baseUrl, model));
|
|
240
282
|
}
|
|
241
283
|
|
|
242
284
|
/** Allowed models for compose UIs that are not integrations admins. */
|
|
@@ -304,35 +346,54 @@ export class AiComposeSuggestService {
|
|
|
304
346
|
}): Promise<string> {
|
|
305
347
|
const { apiKey, baseUrl } = await this.settings.getDecryptedApiKey();
|
|
306
348
|
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
const
|
|
349
|
+
const knowledgeQuery = (input.ragQuery ?? input.userContent).slice(0, 800);
|
|
350
|
+
const knowledge = this.knowledge();
|
|
351
|
+
const knowledgeSnippets = knowledge
|
|
352
|
+
? await knowledge.fetchContext(knowledgeQuery).catch(() => '')
|
|
353
|
+
: '';
|
|
354
|
+
|
|
355
|
+
const systemContent = [input.systemContent, knowledgeSnippets].filter(Boolean).join('\n\n');
|
|
356
|
+
const reasoningEffort = await this.settings.getReasoningEffort();
|
|
357
|
+
const supportsReasoning = isReasoningEffort(reasoningEffort)
|
|
358
|
+
? await this.reasoningSupportFor(baseUrl, apiKey, input.modelUsed)
|
|
359
|
+
: undefined;
|
|
360
|
+
const payload: Record<string, unknown> = {
|
|
361
|
+
model: input.modelUsed,
|
|
362
|
+
messages: [
|
|
363
|
+
{ role: 'system', content: systemContent },
|
|
364
|
+
{ role: 'user', content: input.userContent },
|
|
365
|
+
],
|
|
366
|
+
temperature: 0.7,
|
|
367
|
+
};
|
|
368
|
+
const attempted = applyReasoningEffort(payload, reasoningEffort, supportsReasoning);
|
|
319
369
|
|
|
320
|
-
|
|
370
|
+
let response = await fetch(`${baseUrl}/chat/completions`, {
|
|
321
371
|
method: 'POST',
|
|
322
372
|
headers: {
|
|
323
373
|
'Content-Type': 'application/json',
|
|
324
374
|
Authorization: `Bearer ${apiKey}`,
|
|
325
375
|
},
|
|
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
|
-
}),
|
|
376
|
+
body: JSON.stringify(attempted),
|
|
334
377
|
});
|
|
335
378
|
|
|
379
|
+
if (
|
|
380
|
+
!response.ok &&
|
|
381
|
+
attempted.reasoning_effort &&
|
|
382
|
+
(response.status === 400 || response.status === 422)
|
|
383
|
+
) {
|
|
384
|
+
this.reasoningByModel.set(this.supportKey(baseUrl, input.modelUsed), false);
|
|
385
|
+
response = await fetch(`${baseUrl}/chat/completions`, {
|
|
386
|
+
method: 'POST',
|
|
387
|
+
headers: {
|
|
388
|
+
'Content-Type': 'application/json',
|
|
389
|
+
Authorization: `Bearer ${apiKey}`,
|
|
390
|
+
},
|
|
391
|
+
body: JSON.stringify(payload),
|
|
392
|
+
});
|
|
393
|
+
} else if (response.ok && attempted.reasoning_effort) {
|
|
394
|
+
this.reasoningByModel.set(this.supportKey(baseUrl, input.modelUsed), true);
|
|
395
|
+
}
|
|
396
|
+
|
|
336
397
|
if (!response.ok) {
|
|
337
398
|
const errorText = await response.text().catch(() => 'unknown error');
|
|
338
399
|
this.logger.error(`AI provider error ${response.status}: ${errorText}`);
|
|
@@ -352,162 +413,12 @@ export class AiComposeSuggestService {
|
|
|
352
413
|
return draft;
|
|
353
414
|
}
|
|
354
415
|
|
|
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;
|
|
416
|
+
private originKey(baseUrl: string): string {
|
|
417
|
+
return baseUrl.replace(/\/$/, '');
|
|
407
418
|
}
|
|
408
419
|
|
|
409
|
-
private
|
|
410
|
-
|
|
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
|
-
}
|
|
483
|
-
}
|
|
484
|
-
}
|
|
485
|
-
|
|
486
|
-
/** Exported for unit tests. */
|
|
487
|
-
export function parsePokeloRoute(
|
|
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;
|
|
420
|
+
private supportKey(baseUrl: string, model: string): string {
|
|
421
|
+
return `${this.originKey(baseUrl)}\0${model}`;
|
|
511
422
|
}
|
|
512
423
|
}
|
|
513
424
|
|
package/src/ai-compose.spec.ts
CHANGED
|
@@ -4,7 +4,6 @@ import { AiComposeLlmService } from './ai-compose-llm.service';
|
|
|
4
4
|
import {
|
|
5
5
|
AiComposeSuggestService,
|
|
6
6
|
stripCodeFences,
|
|
7
|
-
parsePokeloRoute,
|
|
8
7
|
} from './ai-compose-suggest.service';
|
|
9
8
|
import { AppException } from '../../../packages/plugin-host/src';
|
|
10
9
|
|
|
@@ -16,11 +15,17 @@ describe('ai-compose-crypto', () => {
|
|
|
16
15
|
const HEX_KEY = 'a'.repeat(64);
|
|
17
16
|
|
|
18
17
|
beforeEach(() => {
|
|
18
|
+
delete process.env.KHIRBY_SECRETS_KEY;
|
|
19
|
+
delete process.env.MAIL_SECRETS_KEY;
|
|
20
|
+
delete process.env.POKELO_SECRETS_KEY;
|
|
19
21
|
process.env.AI_COMPOSE_SECRETS_KEY = HEX_KEY;
|
|
20
22
|
});
|
|
21
23
|
|
|
22
24
|
afterEach(() => {
|
|
25
|
+
delete process.env.KHIRBY_SECRETS_KEY;
|
|
26
|
+
delete process.env.MAIL_SECRETS_KEY;
|
|
23
27
|
delete process.env.AI_COMPOSE_SECRETS_KEY;
|
|
28
|
+
delete process.env.POKELO_SECRETS_KEY;
|
|
24
29
|
});
|
|
25
30
|
|
|
26
31
|
it('encrypts and decrypts back to the same plaintext', () => {
|
|
@@ -42,7 +47,7 @@ describe('ai-compose-crypto', () => {
|
|
|
42
47
|
|
|
43
48
|
it('throws on missing key at encrypt time', () => {
|
|
44
49
|
delete process.env.AI_COMPOSE_SECRETS_KEY;
|
|
45
|
-
expect(() => encrypt('anything')).toThrow('
|
|
50
|
+
expect(() => encrypt('anything')).toThrow('KHIRBY_SECRETS_KEY is not set');
|
|
46
51
|
});
|
|
47
52
|
});
|
|
48
53
|
|
|
@@ -118,6 +123,7 @@ describe('AiComposeSettingsService', () => {
|
|
|
118
123
|
const settings = await service.getSettings();
|
|
119
124
|
expect(settings.apiKeyConfigured).toBe(false);
|
|
120
125
|
expect(settings.baseUrl).toBe('https://api.openai.com/v1');
|
|
126
|
+
expect(settings.reasoningEffort).toBe(null);
|
|
121
127
|
});
|
|
122
128
|
|
|
123
129
|
it('getSettings returns apiKeyConfigured: true when row has apiKeyEnc', async () => {
|
|
@@ -155,6 +161,15 @@ describe('AiComposeSettingsService', () => {
|
|
|
155
161
|
const service = new AiComposeSettingsService(db as any, registry as any);
|
|
156
162
|
await expect(service.updateSettings({ baseUrl: 'http://localhost' })).resolves.toBeDefined();
|
|
157
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
|
+
});
|
|
158
173
|
});
|
|
159
174
|
|
|
160
175
|
// ──────────────────────────────────────────────────────────────────────────────
|
|
@@ -168,6 +183,7 @@ function makeSettingsService(
|
|
|
168
183
|
allowedModels: string[];
|
|
169
184
|
defaultModel: string | null;
|
|
170
185
|
systemPrompt: string | null;
|
|
186
|
+
reasoningEffort: 'none' | 'low' | 'medium' | 'high' | null;
|
|
171
187
|
}> = {},
|
|
172
188
|
) {
|
|
173
189
|
const cfg = {
|
|
@@ -176,6 +192,7 @@ function makeSettingsService(
|
|
|
176
192
|
allowedModels: ['gpt-4o', 'gpt-3.5-turbo'],
|
|
177
193
|
defaultModel: 'gpt-4o',
|
|
178
194
|
systemPrompt: null,
|
|
195
|
+
reasoningEffort: null as 'none' | 'low' | 'medium' | 'high' | null,
|
|
179
196
|
...overrides,
|
|
180
197
|
};
|
|
181
198
|
|
|
@@ -184,10 +201,40 @@ function makeSettingsService(
|
|
|
184
201
|
getAllowedModels: jest.fn().mockResolvedValue(cfg.allowedModels),
|
|
185
202
|
getDefaultModel: jest.fn().mockResolvedValue(cfg.defaultModel),
|
|
186
203
|
getSystemPrompt: jest.fn().mockResolvedValue(cfg.systemPrompt),
|
|
204
|
+
getReasoningEffort: jest.fn().mockResolvedValue(cfg.reasoningEffort),
|
|
187
205
|
assertPluginEnabled: jest.fn().mockResolvedValue(undefined),
|
|
188
206
|
};
|
|
189
207
|
}
|
|
190
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
|
+
|
|
191
238
|
const MOCK_THREAD = {
|
|
192
239
|
id: 'thread-1',
|
|
193
240
|
subject: 'Hello',
|
|
@@ -208,6 +255,13 @@ const MOCK_THREAD = {
|
|
|
208
255
|
],
|
|
209
256
|
};
|
|
210
257
|
|
|
258
|
+
|
|
259
|
+
function mockModuleRef(knowledge: unknown = null) {
|
|
260
|
+
return {
|
|
261
|
+
get: jest.fn().mockReturnValue(knowledge),
|
|
262
|
+
} as any;
|
|
263
|
+
}
|
|
264
|
+
|
|
211
265
|
describe('AiComposeSuggestService', () => {
|
|
212
266
|
const mockMailThreads = {
|
|
213
267
|
getThread: jest.fn().mockResolvedValue(MOCK_THREAD),
|
|
@@ -255,6 +309,7 @@ describe('AiComposeSuggestService', () => {
|
|
|
255
309
|
settings as any,
|
|
256
310
|
mockMailThreads as any,
|
|
257
311
|
mockLeads as any,
|
|
312
|
+
mockModuleRef(),
|
|
258
313
|
);
|
|
259
314
|
|
|
260
315
|
const result = await service.suggest({ threadId: 'thread-1', leadId: 'l-1' });
|
|
@@ -266,12 +321,121 @@ describe('AiComposeSuggestService', () => {
|
|
|
266
321
|
);
|
|
267
322
|
});
|
|
268
323
|
|
|
324
|
+
it('sends reasoning_effort when the catalog advertises it', async () => {
|
|
325
|
+
mockProviderFetch({
|
|
326
|
+
models: [
|
|
327
|
+
{
|
|
328
|
+
id: 'reasoner',
|
|
329
|
+
object: 'model',
|
|
330
|
+
supported_parameters: ['max_tokens', 'reasoning_effort'],
|
|
331
|
+
},
|
|
332
|
+
],
|
|
333
|
+
});
|
|
334
|
+
const settings = makeSettingsService({
|
|
335
|
+
defaultModel: 'reasoner',
|
|
336
|
+
allowedModels: ['reasoner'],
|
|
337
|
+
reasoningEffort: 'high',
|
|
338
|
+
});
|
|
339
|
+
const service = new AiComposeSuggestService(
|
|
340
|
+
settings as any,
|
|
341
|
+
mockMailThreads as any,
|
|
342
|
+
mockLeads as any,
|
|
343
|
+
mockModuleRef(),
|
|
344
|
+
);
|
|
345
|
+
await service.suggest({ threadId: 'thread-1' });
|
|
346
|
+
const body = chatBodyFromFetch();
|
|
347
|
+
expect(body.reasoning_effort).toBe('high');
|
|
348
|
+
expect(body.temperature).toBeUndefined();
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
it('does not send reasoning_effort when the catalog lists params without it', async () => {
|
|
352
|
+
mockProviderFetch({
|
|
353
|
+
models: [
|
|
354
|
+
{
|
|
355
|
+
id: 'gpt-4o',
|
|
356
|
+
object: 'model',
|
|
357
|
+
supported_parameters: ['temperature', 'max_tokens'],
|
|
358
|
+
},
|
|
359
|
+
],
|
|
360
|
+
});
|
|
361
|
+
const settings = makeSettingsService({ reasoningEffort: 'high' });
|
|
362
|
+
const service = new AiComposeSuggestService(
|
|
363
|
+
settings as any,
|
|
364
|
+
mockMailThreads as any,
|
|
365
|
+
mockLeads as any,
|
|
366
|
+
mockModuleRef(),
|
|
367
|
+
);
|
|
368
|
+
await service.suggest({ threadId: 'thread-1' });
|
|
369
|
+
const body = chatBodyFromFetch();
|
|
370
|
+
expect(body.reasoning_effort).toBeUndefined();
|
|
371
|
+
expect(body.temperature).toBe(0.7);
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
it('tries reasoning_effort when the catalog is silent, then drops it after HTTP 400', async () => {
|
|
375
|
+
let completions = 0;
|
|
376
|
+
(global.fetch as jest.Mock).mockImplementation(async (url: string) => {
|
|
377
|
+
if (String(url).includes('/models')) {
|
|
378
|
+
return {
|
|
379
|
+
ok: true,
|
|
380
|
+
json: async () => ({ data: [{ id: 'gpt-4o', object: 'model' }] }),
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
completions += 1;
|
|
384
|
+
if (completions === 1) {
|
|
385
|
+
return { ok: false, status: 400, text: async () => 'unknown parameter' };
|
|
386
|
+
}
|
|
387
|
+
return {
|
|
388
|
+
ok: true,
|
|
389
|
+
json: async () => ({ choices: [{ message: { content: 'ok' } }] }),
|
|
390
|
+
};
|
|
391
|
+
});
|
|
392
|
+
const settings = makeSettingsService({ reasoningEffort: 'high' });
|
|
393
|
+
const service = new AiComposeSuggestService(
|
|
394
|
+
settings as any,
|
|
395
|
+
mockMailThreads as any,
|
|
396
|
+
mockLeads as any,
|
|
397
|
+
mockModuleRef(),
|
|
398
|
+
);
|
|
399
|
+
await service.suggest({ threadId: 'thread-1' });
|
|
400
|
+
expect(completions).toBe(2);
|
|
401
|
+
expect(chatBodyFromFetch(0).reasoning_effort).toBe('high');
|
|
402
|
+
expect(chatBodyFromFetch(1).reasoning_effort).toBeUndefined();
|
|
403
|
+
|
|
404
|
+
await service.suggest({ threadId: 'thread-1' });
|
|
405
|
+
expect(completions).toBe(3);
|
|
406
|
+
expect(chatBodyFromFetch(2).reasoning_effort).toBeUndefined();
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
it('exposes catalog reasoning flags from fetchModels', async () => {
|
|
410
|
+
mockProviderFetch({
|
|
411
|
+
models: [
|
|
412
|
+
{ id: 'a', object: 'model', supported_parameters: ['reasoning'] },
|
|
413
|
+
{ id: 'b', object: 'model', supported_parameters: ['temperature'] },
|
|
414
|
+
{ id: 'c', object: 'model' },
|
|
415
|
+
],
|
|
416
|
+
});
|
|
417
|
+
const service = new AiComposeSuggestService(
|
|
418
|
+
makeSettingsService() as any,
|
|
419
|
+
mockMailThreads as any,
|
|
420
|
+
mockLeads as any,
|
|
421
|
+
mockModuleRef(),
|
|
422
|
+
);
|
|
423
|
+
await expect(
|
|
424
|
+
service.fetchModels('https://api.openai.com/v1', 'sk-test'),
|
|
425
|
+
).resolves.toEqual([
|
|
426
|
+
{ id: 'a', label: 'a', supportsReasoning: true },
|
|
427
|
+
{ id: 'b', label: 'b', supportsReasoning: false },
|
|
428
|
+
{ id: 'c', label: 'c', supportsReasoning: null },
|
|
429
|
+
]);
|
|
430
|
+
});
|
|
431
|
+
|
|
269
432
|
it('throws 400 when model not in allowlist', async () => {
|
|
270
433
|
const settings = makeSettingsService({ allowedModels: ['gpt-4o'] });
|
|
271
434
|
const service = new AiComposeSuggestService(
|
|
272
435
|
settings as any,
|
|
273
436
|
mockMailThreads as any,
|
|
274
437
|
mockLeads as any,
|
|
438
|
+
mockModuleRef(),
|
|
275
439
|
);
|
|
276
440
|
|
|
277
441
|
await expect(service.suggest({ threadId: 'thread-1', model: 'claude-3' })).rejects.toThrow();
|
|
@@ -288,6 +452,7 @@ describe('AiComposeSuggestService', () => {
|
|
|
288
452
|
settings as any,
|
|
289
453
|
mockMailThreads as any,
|
|
290
454
|
mockLeads as any,
|
|
455
|
+
mockModuleRef(),
|
|
291
456
|
);
|
|
292
457
|
|
|
293
458
|
await expect(service.suggest({ threadId: 'thread-1' })).rejects.toThrow(
|
|
@@ -307,6 +472,7 @@ describe('AiComposeSuggestService', () => {
|
|
|
307
472
|
settings as any,
|
|
308
473
|
mockMailThreads as any,
|
|
309
474
|
mockLeads as any,
|
|
475
|
+
mockModuleRef(),
|
|
310
476
|
);
|
|
311
477
|
|
|
312
478
|
await expect(service.suggest({ threadId: 'thread-1' })).rejects.toThrow();
|
|
@@ -323,6 +489,7 @@ describe('AiComposeSuggestService', () => {
|
|
|
323
489
|
settings as any,
|
|
324
490
|
mockMailThreads as any,
|
|
325
491
|
mockLeads as any,
|
|
492
|
+
mockModuleRef(),
|
|
326
493
|
);
|
|
327
494
|
|
|
328
495
|
await service.suggest({ threadId: 'thread-1', instruction: 'Be formal' });
|
|
@@ -346,6 +513,7 @@ describe('AiComposeSuggestService', () => {
|
|
|
346
513
|
settings as any,
|
|
347
514
|
mockMailThreads as any,
|
|
348
515
|
mockLeads as any,
|
|
516
|
+
mockModuleRef(),
|
|
349
517
|
);
|
|
350
518
|
|
|
351
519
|
const result = await service.suggest({ leadId: 'l-1' });
|
|
@@ -369,141 +537,40 @@ describe('AiComposeSuggestService', () => {
|
|
|
369
537
|
settings as any,
|
|
370
538
|
mockMailThreads as any,
|
|
371
539
|
mockLeads as any,
|
|
540
|
+
mockModuleRef(),
|
|
372
541
|
);
|
|
373
542
|
|
|
374
543
|
await expect(service.suggest({})).rejects.toThrow('Either threadId or leadId is required');
|
|
375
544
|
});
|
|
376
545
|
|
|
377
|
-
it('appends
|
|
546
|
+
it('appends knowledge snippets to the system message when a provider is present', async () => {
|
|
378
547
|
(global.fetch as jest.Mock).mockResolvedValue({
|
|
379
548
|
ok: true,
|
|
380
549
|
json: async () => ({ choices: [{ message: { content: 'Draft' } }] }),
|
|
381
550
|
});
|
|
382
551
|
|
|
383
|
-
const
|
|
384
|
-
fetchContext: jest.fn().mockResolvedValue('
|
|
385
|
-
listBoundProjects: jest.fn().mockResolvedValue([{ id: 'p1', name: 'CRM' }]),
|
|
552
|
+
const knowledge = {
|
|
553
|
+
fetchContext: jest.fn().mockResolvedValue('Pricing is X'),
|
|
386
554
|
};
|
|
387
555
|
const settings = makeSettingsService();
|
|
388
556
|
const service = new AiComposeSuggestService(
|
|
389
557
|
settings as any,
|
|
390
558
|
mockMailThreads as any,
|
|
391
559
|
mockLeads as any,
|
|
392
|
-
|
|
560
|
+
mockModuleRef(knowledge),
|
|
393
561
|
);
|
|
394
562
|
|
|
395
563
|
await service.suggest({ threadId: 'thread-1', leadId: 'l-1', instruction: 'Be brief' });
|
|
396
564
|
|
|
397
|
-
expect(
|
|
398
|
-
|
|
399
|
-
|
|
565
|
+
expect(knowledge.fetchContext).toHaveBeenCalledTimes(1);
|
|
566
|
+
expect(knowledge.fetchContext).toHaveBeenCalledWith(expect.any(String));
|
|
567
|
+
expect(knowledge.fetchContext.mock.calls[0][1]).toBeUndefined();
|
|
400
568
|
const body = JSON.parse((global.fetch as jest.Mock).mock.calls[0][1].body);
|
|
401
569
|
const systemMsg = body.messages.find((m: { role: string }) => m.role === 'system');
|
|
402
|
-
expect(systemMsg.content).toContain('Kontekst z Pokelo');
|
|
403
570
|
expect(systemMsg.content).toContain('Pricing is X');
|
|
404
571
|
});
|
|
405
572
|
|
|
406
|
-
it('
|
|
407
|
-
(global.fetch as jest.Mock).mockImplementation(async (_url: string, init: { body: string }) => {
|
|
408
|
-
const body = JSON.parse(init.body);
|
|
409
|
-
const system = body.messages?.[0]?.content ?? '';
|
|
410
|
-
if (typeof system === 'string' && system.includes('route knowledge-base')) {
|
|
411
|
-
// Router payload must match draft call (no max_tokens / no temperature:0)
|
|
412
|
-
expect(body.max_tokens).toBeUndefined();
|
|
413
|
-
expect(body.temperature).toBe(0.7);
|
|
414
|
-
return {
|
|
415
|
-
ok: true,
|
|
416
|
-
json: async () => ({
|
|
417
|
-
choices: [
|
|
418
|
-
{
|
|
419
|
-
message: {
|
|
420
|
-
content: JSON.stringify({
|
|
421
|
-
primary: ['crm'],
|
|
422
|
-
followUp: ['finsly'],
|
|
423
|
-
}),
|
|
424
|
-
},
|
|
425
|
-
},
|
|
426
|
-
],
|
|
427
|
-
}),
|
|
428
|
-
};
|
|
429
|
-
}
|
|
430
|
-
return {
|
|
431
|
-
ok: true,
|
|
432
|
-
json: async () => ({ choices: [{ message: { content: 'Draft' } }] }),
|
|
433
|
-
};
|
|
434
|
-
});
|
|
435
|
-
|
|
436
|
-
const pokelo = {
|
|
437
|
-
listBoundProjects: jest.fn().mockResolvedValue([
|
|
438
|
-
{ id: 'crm', name: 'Bearly CRM' },
|
|
439
|
-
{ id: 'finsly', name: 'Finsly' },
|
|
440
|
-
{ id: 'pokelo', name: 'Pokelo' },
|
|
441
|
-
]),
|
|
442
|
-
fetchContext: jest
|
|
443
|
-
.fn()
|
|
444
|
-
.mockResolvedValueOnce('--- Kontekst z Pokelo ---\n[Bearly CRM] CRM facts')
|
|
445
|
-
.mockResolvedValueOnce('--- Kontekst z Pokelo ---\n[Finsly] Billing facts'),
|
|
446
|
-
};
|
|
447
|
-
|
|
448
|
-
const service = new AiComposeSuggestService(
|
|
449
|
-
makeSettingsService() as any,
|
|
450
|
-
mockMailThreads as any,
|
|
451
|
-
mockLeads as any,
|
|
452
|
-
pokelo as any,
|
|
453
|
-
);
|
|
454
|
-
|
|
455
|
-
await service.suggest({ threadId: 'thread-1', instruction: 'Mention Finsly pricing' });
|
|
456
|
-
|
|
457
|
-
expect(pokelo.fetchContext).toHaveBeenNthCalledWith(1, expect.any(String), {
|
|
458
|
-
projectIds: ['crm'],
|
|
459
|
-
});
|
|
460
|
-
expect(pokelo.fetchContext).toHaveBeenNthCalledWith(2, expect.any(String), {
|
|
461
|
-
projectIds: ['finsly'],
|
|
462
|
-
});
|
|
463
|
-
|
|
464
|
-
const composeCall = (global.fetch as jest.Mock).mock.calls.find((c) => {
|
|
465
|
-
const body = JSON.parse(c[1].body);
|
|
466
|
-
return !String(body.messages?.[0]?.content ?? '').includes('route knowledge-base');
|
|
467
|
-
});
|
|
468
|
-
const systemMsg = JSON.parse(composeCall[1].body).messages.find(
|
|
469
|
-
(m: { role: string }) => m.role === 'system',
|
|
470
|
-
);
|
|
471
|
-
expect(systemMsg.content).toContain('CRM facts');
|
|
472
|
-
expect(systemMsg.content).toContain('Billing facts');
|
|
473
|
-
});
|
|
474
|
-
|
|
475
|
-
it('searches both projects directly when exactly two are bound (no router)', async () => {
|
|
476
|
-
(global.fetch as jest.Mock).mockResolvedValue({
|
|
477
|
-
ok: true,
|
|
478
|
-
json: async () => ({ choices: [{ message: { content: 'Draft' } }] }),
|
|
479
|
-
});
|
|
480
|
-
|
|
481
|
-
const pokelo = {
|
|
482
|
-
listBoundProjects: jest.fn().mockResolvedValue([
|
|
483
|
-
{ id: 'crm', name: 'Bearly CRM' },
|
|
484
|
-
{ id: 'finsly', name: 'Finsly' },
|
|
485
|
-
]),
|
|
486
|
-
fetchContext: jest.fn().mockResolvedValue('--- Kontekst z Pokelo ---\n[CRM] a\n[Finsly] b'),
|
|
487
|
-
};
|
|
488
|
-
|
|
489
|
-
const service = new AiComposeSuggestService(
|
|
490
|
-
makeSettingsService() as any,
|
|
491
|
-
mockMailThreads as any,
|
|
492
|
-
mockLeads as any,
|
|
493
|
-
pokelo as any,
|
|
494
|
-
);
|
|
495
|
-
|
|
496
|
-
await service.suggest({ threadId: 'thread-1', instruction: 'Hello' });
|
|
497
|
-
|
|
498
|
-
expect(pokelo.fetchContext).toHaveBeenCalledTimes(1);
|
|
499
|
-
expect(pokelo.fetchContext).toHaveBeenCalledWith(expect.any(String), {
|
|
500
|
-
projectIds: ['crm', 'finsly'],
|
|
501
|
-
});
|
|
502
|
-
// Only the compose completion — no router call
|
|
503
|
-
expect(global.fetch).toHaveBeenCalledTimes(1);
|
|
504
|
-
});
|
|
505
|
-
|
|
506
|
-
it('works without Pokelo when context service is null', async () => {
|
|
573
|
+
it('works without a knowledge provider', async () => {
|
|
507
574
|
(global.fetch as jest.Mock).mockResolvedValue({
|
|
508
575
|
ok: true,
|
|
509
576
|
json: async () => ({ choices: [{ message: { content: 'Draft' } }] }),
|
|
@@ -514,13 +581,13 @@ describe('AiComposeSuggestService', () => {
|
|
|
514
581
|
settings as any,
|
|
515
582
|
mockMailThreads as any,
|
|
516
583
|
mockLeads as any,
|
|
517
|
-
|
|
584
|
+
mockModuleRef(),
|
|
518
585
|
);
|
|
519
586
|
|
|
520
587
|
await service.suggest({ threadId: 'thread-1' });
|
|
521
588
|
const body = JSON.parse((global.fetch as jest.Mock).mock.calls[0][1].body);
|
|
522
589
|
const systemMsg = body.messages.find((m: { role: string }) => m.role === 'system');
|
|
523
|
-
expect(systemMsg.content).not.toContain('
|
|
590
|
+
expect(systemMsg.content).not.toContain('Pricing is X');
|
|
524
591
|
});
|
|
525
592
|
});
|
|
526
593
|
|
|
@@ -538,7 +605,12 @@ describe('AiComposeSuggestService.generateNewsletter', () => {
|
|
|
538
605
|
});
|
|
539
606
|
|
|
540
607
|
function service(settings = makeSettingsService()) {
|
|
541
|
-
return new AiComposeSuggestService(
|
|
608
|
+
return new AiComposeSuggestService(
|
|
609
|
+
settings as any,
|
|
610
|
+
mockMailThreads as any,
|
|
611
|
+
mockLeads as any,
|
|
612
|
+
mockModuleRef(),
|
|
613
|
+
);
|
|
542
614
|
}
|
|
543
615
|
|
|
544
616
|
it('asks the model for HTML and strips fences', async () => {
|
|
@@ -626,21 +698,20 @@ describe('AiComposeSuggestService.generateNewsletter', () => {
|
|
|
626
698
|
expect(userMsg.content).toContain('## Old draft');
|
|
627
699
|
});
|
|
628
700
|
|
|
629
|
-
it('appends
|
|
701
|
+
it('appends knowledge snippets for newsletter generate', async () => {
|
|
630
702
|
(global.fetch as jest.Mock).mockResolvedValue({
|
|
631
703
|
ok: true,
|
|
632
704
|
json: async () => ({ choices: [{ message: { content: '<p>Hi</p>' } }] }),
|
|
633
705
|
});
|
|
634
706
|
|
|
635
|
-
const
|
|
636
|
-
fetchContext: jest.fn().mockResolvedValue('
|
|
637
|
-
listBoundProjects: jest.fn().mockResolvedValue([{ id: 'p1', name: 'CRM' }]),
|
|
707
|
+
const knowledge = {
|
|
708
|
+
fetchContext: jest.fn().mockResolvedValue('Brand voice: warm'),
|
|
638
709
|
};
|
|
639
710
|
const svc = new AiComposeSuggestService(
|
|
640
711
|
makeSettingsService() as any,
|
|
641
712
|
mockMailThreads as any,
|
|
642
713
|
mockLeads as any,
|
|
643
|
-
|
|
714
|
+
mockModuleRef(knowledge),
|
|
644
715
|
);
|
|
645
716
|
|
|
646
717
|
await svc.generateNewsletter({
|
|
@@ -650,37 +721,13 @@ describe('AiComposeSuggestService.generateNewsletter', () => {
|
|
|
650
721
|
instruction: 'Product news',
|
|
651
722
|
});
|
|
652
723
|
|
|
653
|
-
expect(
|
|
654
|
-
projectIds: ['p1'],
|
|
655
|
-
});
|
|
724
|
+
expect(knowledge.fetchContext).toHaveBeenCalledWith(expect.stringContaining('Product news'));
|
|
656
725
|
const body = JSON.parse((global.fetch as jest.Mock).mock.calls[0][1].body);
|
|
657
726
|
const systemMsg = body.messages.find((m: { role: string }) => m.role === 'system');
|
|
658
727
|
expect(systemMsg.content).toContain('Brand voice: warm');
|
|
659
728
|
});
|
|
660
729
|
});
|
|
661
730
|
|
|
662
|
-
describe('parsePokeloRoute', () => {
|
|
663
|
-
it('extracts primary and followUp IDs from JSON', () => {
|
|
664
|
-
const route = parsePokeloRoute('Here you go:\n{"primary":["a","b"],"followUp":["c"]}\n', [
|
|
665
|
-
'a',
|
|
666
|
-
'b',
|
|
667
|
-
'c',
|
|
668
|
-
'd',
|
|
669
|
-
]);
|
|
670
|
-
expect(route).toEqual({ primary: ['a', 'b'], followUp: ['c'] });
|
|
671
|
-
});
|
|
672
|
-
|
|
673
|
-
it('drops unknown IDs and caps lengths', () => {
|
|
674
|
-
const route = parsePokeloRoute('{"primary":["a","b","x","y"],"followUp":["c","d"]}', [
|
|
675
|
-
'a',
|
|
676
|
-
'b',
|
|
677
|
-
'c',
|
|
678
|
-
]);
|
|
679
|
-
expect(route.primary).toEqual(['a', 'b']);
|
|
680
|
-
expect(route.followUp).toEqual(['c']);
|
|
681
|
-
});
|
|
682
|
-
});
|
|
683
|
-
|
|
684
731
|
describe('stripCodeFences', () => {
|
|
685
732
|
it('unwraps fenced blocks', () => {
|
|
686
733
|
expect(stripCodeFences('```md\n# Hi\n```')).toBe('# Hi');
|
|
@@ -692,7 +739,7 @@ describe('stripCodeFences', () => {
|
|
|
692
739
|
});
|
|
693
740
|
|
|
694
741
|
describe('AiComposeLlmService', () => {
|
|
695
|
-
it('
|
|
742
|
+
it('throws when no default model is set', async () => {
|
|
696
743
|
const settings = {
|
|
697
744
|
assertPluginEnabled: jest.fn().mockResolvedValue(undefined),
|
|
698
745
|
getDecryptedApiKey: jest.fn().mockResolvedValue({
|
|
@@ -700,10 +747,11 @@ describe('AiComposeLlmService', () => {
|
|
|
700
747
|
baseUrl: 'https://api.openai.com/v1',
|
|
701
748
|
}),
|
|
702
749
|
getDefaultModel: jest.fn().mockResolvedValue(null),
|
|
750
|
+
getReasoningEffort: jest.fn().mockResolvedValue(null),
|
|
703
751
|
} as unknown as AiComposeSettingsService;
|
|
704
752
|
|
|
705
|
-
const svc = new AiComposeLlmService(settings);
|
|
706
|
-
await expect(svc.getCompletionConfig()).
|
|
753
|
+
const svc = new AiComposeLlmService(settings, { reasoningSupportFor: jest.fn() } as any);
|
|
754
|
+
await expect(svc.getCompletionConfig()).rejects.toThrow(/No default model configured/);
|
|
707
755
|
});
|
|
708
756
|
|
|
709
757
|
it('returns BYOK config when settings are complete', async () => {
|
|
@@ -714,13 +762,25 @@ describe('AiComposeLlmService', () => {
|
|
|
714
762
|
baseUrl: 'https://api.openai.com/v1',
|
|
715
763
|
}),
|
|
716
764
|
getDefaultModel: jest.fn().mockResolvedValue('gpt-4o-mini'),
|
|
765
|
+
getReasoningEffort: jest.fn().mockResolvedValue('medium'),
|
|
717
766
|
} as unknown as AiComposeSettingsService;
|
|
767
|
+
const suggest = {
|
|
768
|
+
cachedReasoningSupport: jest.fn().mockReturnValue(true),
|
|
769
|
+
reasoningSupportFor: jest.fn(),
|
|
770
|
+
};
|
|
718
771
|
|
|
719
|
-
const svc = new AiComposeLlmService(settings);
|
|
772
|
+
const svc = new AiComposeLlmService(settings, suggest as any);
|
|
720
773
|
await expect(svc.getCompletionConfig()).resolves.toEqual({
|
|
721
774
|
apiKey: 'sk-test',
|
|
722
775
|
baseUrl: 'https://api.openai.com/v1',
|
|
723
776
|
model: 'gpt-4o-mini',
|
|
777
|
+
reasoningEffort: 'medium',
|
|
778
|
+
reasoningSupported: true,
|
|
724
779
|
});
|
|
780
|
+
expect(suggest.cachedReasoningSupport).toHaveBeenCalledWith(
|
|
781
|
+
'https://api.openai.com/v1',
|
|
782
|
+
'gpt-4o-mini',
|
|
783
|
+
);
|
|
784
|
+
expect(suggest.reasoningSupportFor).not.toHaveBeenCalled();
|
|
725
785
|
});
|
|
726
786
|
});
|
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
|
});
|