@khirby/plugin-ai-compose 1.1.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@khirby/plugin-ai-compose",
3
- "version": "1.1.0",
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": [
@@ -1,60 +1,5 @@
1
- import { createCipheriv, createDecipheriv, randomBytes } from 'crypto';
2
-
3
- const ALGORITHM = 'aes-256-gcm';
4
- const IV_BYTES = 12;
5
- const TAG_BYTES = 16;
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 type { AiComposeLlmLike } from '../../../packages/plugin-host/src';
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
- constructor(private readonly settings: AiComposeSettingsService) {}
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
- try {
16
- await this.settings.assertPluginEnabled();
17
- const { apiKey, baseUrl } = await this.settings.getDecryptedApiKey();
18
- const model = await this.settings.getDefaultModel();
19
- if (!model?.trim()) return null;
20
- return { baseUrl, apiKey, model: model.trim() };
21
- } catch {
22
- return 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
+ }
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().catch(() => {
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('AI_COMPOSE_SECRETS_KEY is not configured');
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.badRequest('AI Compose API key is not configured');
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
- POKELO_CONTEXT_SERVICE,
5
+ KNOWLEDGE_CONTEXT,
6
6
  type MailThreadServiceLike,
7
- type PokeloContextServiceLike,
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(POKELO_CONTEXT_SERVICE)
43
- private readonly pokeloContext: PokeloContextServiceLike | null = null,
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(baseUrl: string, apiKey: string): Promise<{ id: string; label: string }[]> {
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: string; object?: string }>;
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) => ({ id: m.id, label: m.id }));
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
- let pokeloSnippets = '';
308
- if (this.pokeloContext) {
309
- const query = (input.ragQuery ?? input.userContent).slice(0, 800);
310
- pokeloSnippets = await this.resolvePokeloSnippets({
311
- query,
312
- apiKey,
313
- baseUrl,
314
- modelUsed: input.modelUsed,
315
- }).catch(() => '');
316
- }
317
-
318
- const systemContent = [input.systemContent, pokeloSnippets].filter(Boolean).join('\n\n');
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
- const response = await fetch(`${baseUrl}/chat/completions`, {
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
- * Multi-project Pokelo RAG (ADR-0022):
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
- /** 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;
414
+ private supportKey(baseUrl: string, model: string): string {
415
+ return `${this.originKey(baseUrl)}\0${model}`;
511
416
  }
512
417
  }
513
418
 
@@ -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('AI_COMPOSE_SECRETS_KEY is not set');
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',
@@ -255,6 +302,7 @@ describe('AiComposeSuggestService', () => {
255
302
  settings as any,
256
303
  mockMailThreads as any,
257
304
  mockLeads as any,
305
+ null,
258
306
  );
259
307
 
260
308
  const result = await service.suggest({ threadId: 'thread-1', leadId: 'l-1' });
@@ -266,12 +314,121 @@ describe('AiComposeSuggestService', () => {
266
314
  );
267
315
  });
268
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
+
269
425
  it('throws 400 when model not in allowlist', async () => {
270
426
  const settings = makeSettingsService({ allowedModels: ['gpt-4o'] });
271
427
  const service = new AiComposeSuggestService(
272
428
  settings as any,
273
429
  mockMailThreads as any,
274
430
  mockLeads as any,
431
+ null,
275
432
  );
276
433
 
277
434
  await expect(service.suggest({ threadId: 'thread-1', model: 'claude-3' })).rejects.toThrow();
@@ -288,6 +445,7 @@ describe('AiComposeSuggestService', () => {
288
445
  settings as any,
289
446
  mockMailThreads as any,
290
447
  mockLeads as any,
448
+ null,
291
449
  );
292
450
 
293
451
  await expect(service.suggest({ threadId: 'thread-1' })).rejects.toThrow(
@@ -307,6 +465,7 @@ describe('AiComposeSuggestService', () => {
307
465
  settings as any,
308
466
  mockMailThreads as any,
309
467
  mockLeads as any,
468
+ null,
310
469
  );
311
470
 
312
471
  await expect(service.suggest({ threadId: 'thread-1' })).rejects.toThrow();
@@ -323,6 +482,7 @@ describe('AiComposeSuggestService', () => {
323
482
  settings as any,
324
483
  mockMailThreads as any,
325
484
  mockLeads as any,
485
+ null,
326
486
  );
327
487
 
328
488
  await service.suggest({ threadId: 'thread-1', instruction: 'Be formal' });
@@ -346,6 +506,7 @@ describe('AiComposeSuggestService', () => {
346
506
  settings as any,
347
507
  mockMailThreads as any,
348
508
  mockLeads as any,
509
+ null,
349
510
  );
350
511
 
351
512
  const result = await service.suggest({ leadId: 'l-1' });
@@ -369,141 +530,40 @@ describe('AiComposeSuggestService', () => {
369
530
  settings as any,
370
531
  mockMailThreads as any,
371
532
  mockLeads as any,
533
+ null,
372
534
  );
373
535
 
374
536
  await expect(service.suggest({})).rejects.toThrow('Either threadId or leadId is required');
375
537
  });
376
538
 
377
- it('appends Pokelo snippets to the system message when context service is present', async () => {
539
+ it('appends knowledge snippets to the system message when a provider is present', async () => {
378
540
  (global.fetch as jest.Mock).mockResolvedValue({
379
541
  ok: true,
380
542
  json: async () => ({ choices: [{ message: { content: 'Draft' } }] }),
381
543
  });
382
544
 
383
- const pokelo = {
384
- fetchContext: jest.fn().mockResolvedValue('--- Kontekst z Pokelo ---\nPricing is X'),
385
- listBoundProjects: jest.fn().mockResolvedValue([{ id: 'p1', name: 'CRM' }]),
545
+ const knowledge = {
546
+ fetchContext: jest.fn().mockResolvedValue('Pricing is X'),
386
547
  };
387
548
  const settings = makeSettingsService();
388
549
  const service = new AiComposeSuggestService(
389
550
  settings as any,
390
551
  mockMailThreads as any,
391
552
  mockLeads as any,
392
- pokelo as any,
553
+ knowledge as any,
393
554
  );
394
555
 
395
556
  await service.suggest({ threadId: 'thread-1', leadId: 'l-1', instruction: 'Be brief' });
396
557
 
397
- expect(pokelo.fetchContext).toHaveBeenCalledWith(expect.any(String), {
398
- projectIds: ['p1'],
399
- });
558
+ expect(knowledge.fetchContext).toHaveBeenCalledTimes(1);
559
+ expect(knowledge.fetchContext).toHaveBeenCalledWith(expect.any(String));
560
+ expect(knowledge.fetchContext.mock.calls[0][1]).toBeUndefined();
400
561
  const body = JSON.parse((global.fetch as jest.Mock).mock.calls[0][1].body);
401
562
  const systemMsg = body.messages.find((m: { role: string }) => m.role === 'system');
402
- expect(systemMsg.content).toContain('Kontekst z Pokelo');
403
563
  expect(systemMsg.content).toContain('Pricing is X');
404
564
  });
405
565
 
406
- it('routes across multiple Pokelo projects then fetches follow-up', async () => {
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 () => {
566
+ it('works without a knowledge provider', async () => {
507
567
  (global.fetch as jest.Mock).mockResolvedValue({
508
568
  ok: true,
509
569
  json: async () => ({ choices: [{ message: { content: 'Draft' } }] }),
@@ -520,7 +580,7 @@ describe('AiComposeSuggestService', () => {
520
580
  await service.suggest({ threadId: 'thread-1' });
521
581
  const body = JSON.parse((global.fetch as jest.Mock).mock.calls[0][1].body);
522
582
  const systemMsg = body.messages.find((m: { role: string }) => m.role === 'system');
523
- expect(systemMsg.content).not.toContain('Kontekst z Pokelo');
583
+ expect(systemMsg.content).not.toContain('Pricing is X');
524
584
  });
525
585
  });
526
586
 
@@ -538,7 +598,12 @@ describe('AiComposeSuggestService.generateNewsletter', () => {
538
598
  });
539
599
 
540
600
  function service(settings = makeSettingsService()) {
541
- return new AiComposeSuggestService(settings as any, mockMailThreads as any, mockLeads as any);
601
+ return new AiComposeSuggestService(
602
+ settings as any,
603
+ mockMailThreads as any,
604
+ mockLeads as any,
605
+ null,
606
+ );
542
607
  }
543
608
 
544
609
  it('asks the model for HTML and strips fences', async () => {
@@ -626,21 +691,20 @@ describe('AiComposeSuggestService.generateNewsletter', () => {
626
691
  expect(userMsg.content).toContain('## Old draft');
627
692
  });
628
693
 
629
- it('appends Pokelo snippets for newsletter generate', async () => {
694
+ it('appends knowledge snippets for newsletter generate', async () => {
630
695
  (global.fetch as jest.Mock).mockResolvedValue({
631
696
  ok: true,
632
697
  json: async () => ({ choices: [{ message: { content: '<p>Hi</p>' } }] }),
633
698
  });
634
699
 
635
- const pokelo = {
636
- fetchContext: jest.fn().mockResolvedValue('--- Kontekst z Pokelo ---\nBrand voice: warm'),
637
- listBoundProjects: jest.fn().mockResolvedValue([{ id: 'p1', name: 'CRM' }]),
700
+ const knowledge = {
701
+ fetchContext: jest.fn().mockResolvedValue('Brand voice: warm'),
638
702
  };
639
703
  const svc = new AiComposeSuggestService(
640
704
  makeSettingsService() as any,
641
705
  mockMailThreads as any,
642
706
  mockLeads as any,
643
- pokelo as any,
707
+ knowledge as any,
644
708
  );
645
709
 
646
710
  await svc.generateNewsletter({
@@ -650,37 +714,13 @@ describe('AiComposeSuggestService.generateNewsletter', () => {
650
714
  instruction: 'Product news',
651
715
  });
652
716
 
653
- expect(pokelo.fetchContext).toHaveBeenCalledWith(expect.stringContaining('Product news'), {
654
- projectIds: ['p1'],
655
- });
717
+ expect(knowledge.fetchContext).toHaveBeenCalledWith(expect.stringContaining('Product news'));
656
718
  const body = JSON.parse((global.fetch as jest.Mock).mock.calls[0][1].body);
657
719
  const systemMsg = body.messages.find((m: { role: string }) => m.role === 'system');
658
720
  expect(systemMsg.content).toContain('Brand voice: warm');
659
721
  });
660
722
  });
661
723
 
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
724
  describe('stripCodeFences', () => {
685
725
  it('unwraps fenced blocks', () => {
686
726
  expect(stripCodeFences('```md\n# Hi\n```')).toBe('# Hi');
@@ -692,7 +732,7 @@ describe('stripCodeFences', () => {
692
732
  });
693
733
 
694
734
  describe('AiComposeLlmService', () => {
695
- it('returns null when no default model is set', async () => {
735
+ it('throws when no default model is set', async () => {
696
736
  const settings = {
697
737
  assertPluginEnabled: jest.fn().mockResolvedValue(undefined),
698
738
  getDecryptedApiKey: jest.fn().mockResolvedValue({
@@ -700,10 +740,11 @@ describe('AiComposeLlmService', () => {
700
740
  baseUrl: 'https://api.openai.com/v1',
701
741
  }),
702
742
  getDefaultModel: jest.fn().mockResolvedValue(null),
743
+ getReasoningEffort: jest.fn().mockResolvedValue(null),
703
744
  } as unknown as AiComposeSettingsService;
704
745
 
705
- const svc = new AiComposeLlmService(settings);
706
- await expect(svc.getCompletionConfig()).resolves.toBeNull();
746
+ const svc = new AiComposeLlmService(settings, { reasoningSupportFor: jest.fn() } as any);
747
+ await expect(svc.getCompletionConfig()).rejects.toThrow(/No default model configured/);
707
748
  });
708
749
 
709
750
  it('returns BYOK config when settings are complete', async () => {
@@ -714,13 +755,25 @@ describe('AiComposeLlmService', () => {
714
755
  baseUrl: 'https://api.openai.com/v1',
715
756
  }),
716
757
  getDefaultModel: jest.fn().mockResolvedValue('gpt-4o-mini'),
758
+ getReasoningEffort: jest.fn().mockResolvedValue('medium'),
717
759
  } as unknown as AiComposeSettingsService;
760
+ const suggest = {
761
+ cachedReasoningSupport: jest.fn().mockReturnValue(true),
762
+ reasoningSupportFor: jest.fn(),
763
+ };
718
764
 
719
- const svc = new AiComposeLlmService(settings);
765
+ const svc = new AiComposeLlmService(settings, suggest as any);
720
766
  await expect(svc.getCompletionConfig()).resolves.toEqual({
721
767
  apiKey: 'sk-test',
722
768
  baseUrl: 'https://api.openai.com/v1',
723
769
  model: 'gpt-4o-mini',
770
+ reasoningEffort: 'medium',
771
+ reasoningSupported: true,
724
772
  });
773
+ expect(suggest.cachedReasoningSupport).toHaveBeenCalledWith(
774
+ 'https://api.openai.com/v1',
775
+ 'gpt-4o-mini',
776
+ );
777
+ expect(suggest.reasoningSupportFor).not.toHaveBeenCalled();
725
778
  });
726
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
  });