omnikey-cli 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/backend-dist/__tests__/ai-client.adapters.test.js +247 -0
- package/backend-dist/__tests__/ai-client.helpers.test.js +99 -0
- package/backend-dist/__tests__/featureRoutes.runEnhancementModel.test.js +79 -0
- package/backend-dist/agent/agentServer.js +12 -7
- package/backend-dist/ai-client.js +54 -6
- package/backend-dist/db.js +62 -0
- package/backend-dist/featureRoutes.js +13 -7
- package/backend-dist/index.js +14 -10
- package/backend-dist/mcpServerRoutes.js +6 -1
- package/package.json +1 -1
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Per-provider adapter tests for temperature handling.
|
|
4
|
+
*
|
|
5
|
+
* The three SDKs (`openai`, `@anthropic-ai/sdk`, `@google/genai`) are mocked
|
|
6
|
+
* at the module boundary using `vi.mock`. Mock spies are declared inside a
|
|
7
|
+
* `vi.hoisted()` block so they are available when `vi.mock` factories run
|
|
8
|
+
* (vi.mock is hoisted to the top of the file).
|
|
9
|
+
*
|
|
10
|
+
* These tests never contact any real API.
|
|
11
|
+
*/
|
|
12
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
13
|
+
const vitest_1 = require("vitest");
|
|
14
|
+
const mocks = vitest_1.vi.hoisted(() => ({
|
|
15
|
+
openaiCreate: vitest_1.vi.fn(),
|
|
16
|
+
anthropicCreate: vitest_1.vi.fn(),
|
|
17
|
+
anthropicStream: vitest_1.vi.fn(),
|
|
18
|
+
geminiGenerate: vitest_1.vi.fn(),
|
|
19
|
+
geminiGenerateStream: vitest_1.vi.fn(),
|
|
20
|
+
}));
|
|
21
|
+
vitest_1.vi.mock('openai', () => ({
|
|
22
|
+
default: class MockOpenAI {
|
|
23
|
+
constructor(_opts) {
|
|
24
|
+
this.chat = { completions: { create: mocks.openaiCreate } };
|
|
25
|
+
this.images = { generate: vitest_1.vi.fn() };
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
}));
|
|
29
|
+
vitest_1.vi.mock('@anthropic-ai/sdk', () => ({
|
|
30
|
+
default: class MockAnthropic {
|
|
31
|
+
constructor(_opts) {
|
|
32
|
+
this.messages = { create: mocks.anthropicCreate, stream: mocks.anthropicStream };
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
}));
|
|
36
|
+
vitest_1.vi.mock('@google/genai', () => ({
|
|
37
|
+
GoogleGenAI: class MockGoogleGenAI {
|
|
38
|
+
constructor(_opts) {
|
|
39
|
+
this.models = {
|
|
40
|
+
generateContent: mocks.geminiGenerate,
|
|
41
|
+
generateContentStream: mocks.geminiGenerateStream,
|
|
42
|
+
generateImages: vitest_1.vi.fn(),
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
},
|
|
46
|
+
// The adapter file imports these as types-only but they still need to resolve.
|
|
47
|
+
Content: class {
|
|
48
|
+
},
|
|
49
|
+
Tool: class {
|
|
50
|
+
},
|
|
51
|
+
}));
|
|
52
|
+
const ai_client_1 = require("../ai-client");
|
|
53
|
+
const messages = [{ role: 'user', content: 'hello' }];
|
|
54
|
+
function asAsyncIterable(chunks) {
|
|
55
|
+
return {
|
|
56
|
+
[Symbol.asyncIterator]: async function* () {
|
|
57
|
+
for (const c of chunks)
|
|
58
|
+
yield c;
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
(0, vitest_1.beforeEach)(() => {
|
|
63
|
+
mocks.openaiCreate.mockReset();
|
|
64
|
+
mocks.anthropicCreate.mockReset();
|
|
65
|
+
mocks.anthropicStream.mockReset();
|
|
66
|
+
mocks.geminiGenerate.mockReset();
|
|
67
|
+
mocks.geminiGenerateStream.mockReset();
|
|
68
|
+
});
|
|
69
|
+
// ---------------------------------------------------------------------------
|
|
70
|
+
// OpenAI
|
|
71
|
+
// ---------------------------------------------------------------------------
|
|
72
|
+
(0, vitest_1.describe)('OpenAIAdapter temperature handling', () => {
|
|
73
|
+
function mockCompleteResponse() {
|
|
74
|
+
mocks.openaiCreate.mockResolvedValueOnce({
|
|
75
|
+
choices: [{ message: { content: 'ok', tool_calls: undefined }, finish_reason: 'stop' }],
|
|
76
|
+
usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 },
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
function mockStreamResponse() {
|
|
80
|
+
mocks.openaiCreate.mockResolvedValueOnce(asAsyncIterable([
|
|
81
|
+
{ choices: [{ delta: { content: 'ok' } }] },
|
|
82
|
+
{
|
|
83
|
+
choices: [{ delta: {} }],
|
|
84
|
+
usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 },
|
|
85
|
+
},
|
|
86
|
+
]));
|
|
87
|
+
}
|
|
88
|
+
(0, vitest_1.it)('complete: passes temperature for gpt-4o-mini', async () => {
|
|
89
|
+
mockCompleteResponse();
|
|
90
|
+
const client = new ai_client_1.AIClient('openai', 'sk-test');
|
|
91
|
+
await client.complete('gpt-4o-mini', messages, { temperature: 0.42 });
|
|
92
|
+
const body = mocks.openaiCreate.mock.calls[0][0];
|
|
93
|
+
(0, vitest_1.expect)(body).toHaveProperty('temperature', 0.42);
|
|
94
|
+
});
|
|
95
|
+
(0, vitest_1.it)('complete: omits temperature for gpt-5.5 even if caller supplies one', async () => {
|
|
96
|
+
mockCompleteResponse();
|
|
97
|
+
const client = new ai_client_1.AIClient('openai', 'sk-test');
|
|
98
|
+
await client.complete('gpt-5.5', messages, { temperature: 0.42 });
|
|
99
|
+
const body = mocks.openaiCreate.mock.calls[0][0];
|
|
100
|
+
(0, vitest_1.expect)(body).not.toHaveProperty('temperature');
|
|
101
|
+
});
|
|
102
|
+
vitest_1.it.each(['gpt-5', 'gpt-5-mini', 'gpt-5.1', 'o1', 'o3-mini', 'o4-mini'])('complete: omits temperature for unsupported model %s', async (model) => {
|
|
103
|
+
mockCompleteResponse();
|
|
104
|
+
const client = new ai_client_1.AIClient('openai', 'sk-test');
|
|
105
|
+
await client.complete(model, messages, { temperature: 0.7 });
|
|
106
|
+
const body = mocks.openaiCreate.mock.calls[0][0];
|
|
107
|
+
(0, vitest_1.expect)(body).not.toHaveProperty('temperature');
|
|
108
|
+
});
|
|
109
|
+
(0, vitest_1.it)('streamComplete: passes temperature for gpt-4o-mini', async () => {
|
|
110
|
+
mockStreamResponse();
|
|
111
|
+
const client = new ai_client_1.AIClient('openai', 'sk-test');
|
|
112
|
+
await client.streamComplete('gpt-4o-mini', messages, { temperature: 0.31 }, () => { });
|
|
113
|
+
const body = mocks.openaiCreate.mock.calls[0][0];
|
|
114
|
+
(0, vitest_1.expect)(body).toHaveProperty('temperature', 0.31);
|
|
115
|
+
(0, vitest_1.expect)(body).toHaveProperty('stream', true);
|
|
116
|
+
});
|
|
117
|
+
(0, vitest_1.it)('streamComplete: omits temperature for gpt-5.5', async () => {
|
|
118
|
+
mockStreamResponse();
|
|
119
|
+
const client = new ai_client_1.AIClient('openai', 'sk-test');
|
|
120
|
+
await client.streamComplete('gpt-5.5', messages, { temperature: 0.31 }, () => { });
|
|
121
|
+
const body = mocks.openaiCreate.mock.calls[0][0];
|
|
122
|
+
(0, vitest_1.expect)(body).not.toHaveProperty('temperature');
|
|
123
|
+
(0, vitest_1.expect)(body).toHaveProperty('stream', true);
|
|
124
|
+
});
|
|
125
|
+
(0, vitest_1.it)('streamComplete: omits temperature even when caller passes empty options for gpt-5.5', async () => {
|
|
126
|
+
mockStreamResponse();
|
|
127
|
+
const client = new ai_client_1.AIClient('openai', 'sk-test');
|
|
128
|
+
await client.streamComplete('gpt-5.5', messages, {}, () => { });
|
|
129
|
+
const body = mocks.openaiCreate.mock.calls[0][0];
|
|
130
|
+
(0, vitest_1.expect)(body).not.toHaveProperty('temperature');
|
|
131
|
+
});
|
|
132
|
+
(0, vitest_1.it)('streamComplete: uses 0.3 default for supported model when caller omits temperature', async () => {
|
|
133
|
+
mockStreamResponse();
|
|
134
|
+
const client = new ai_client_1.AIClient('openai', 'sk-test');
|
|
135
|
+
await client.streamComplete('gpt-4o-mini', messages, {}, () => { });
|
|
136
|
+
const body = mocks.openaiCreate.mock.calls[0][0];
|
|
137
|
+
(0, vitest_1.expect)(body).toHaveProperty('temperature', 0.3);
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
// ---------------------------------------------------------------------------
|
|
141
|
+
// Anthropic
|
|
142
|
+
// ---------------------------------------------------------------------------
|
|
143
|
+
(0, vitest_1.describe)('AnthropicAdapter temperature handling', () => {
|
|
144
|
+
function mockCompleteResponse() {
|
|
145
|
+
mocks.anthropicCreate.mockResolvedValueOnce({
|
|
146
|
+
content: [{ type: 'text', text: 'ok' }],
|
|
147
|
+
stop_reason: 'end_turn',
|
|
148
|
+
usage: { input_tokens: 1, output_tokens: 1 },
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
function mockStreamResponse() {
|
|
152
|
+
const finalMessage = vitest_1.vi.fn().mockResolvedValue({
|
|
153
|
+
usage: { input_tokens: 1, output_tokens: 1 },
|
|
154
|
+
});
|
|
155
|
+
const stream = asAsyncIterable([
|
|
156
|
+
{ type: 'content_block_delta', delta: { type: 'text_delta', text: 'ok' } },
|
|
157
|
+
]);
|
|
158
|
+
stream.finalMessage = finalMessage;
|
|
159
|
+
mocks.anthropicStream.mockReturnValueOnce(stream);
|
|
160
|
+
}
|
|
161
|
+
(0, vitest_1.it)('complete: passes temperature for claude-sonnet-4-5', async () => {
|
|
162
|
+
mockCompleteResponse();
|
|
163
|
+
const client = new ai_client_1.AIClient('anthropic', 'sk-anthropic-test');
|
|
164
|
+
await client.complete('claude-sonnet-4-5', messages, { temperature: 0.42 });
|
|
165
|
+
const body = mocks.anthropicCreate.mock.calls[0][0];
|
|
166
|
+
(0, vitest_1.expect)(body).toHaveProperty('temperature', 0.42);
|
|
167
|
+
});
|
|
168
|
+
vitest_1.it.each([
|
|
169
|
+
'claude-haiku-4-5-20251001',
|
|
170
|
+
'claude-opus-4-5',
|
|
171
|
+
'claude-opus-4-5-20251101',
|
|
172
|
+
'claude-opus-4-6',
|
|
173
|
+
])('complete: passes temperature for supported model %s', async (model) => {
|
|
174
|
+
mockCompleteResponse();
|
|
175
|
+
const client = new ai_client_1.AIClient('anthropic', 'sk-anthropic-test');
|
|
176
|
+
await client.complete(model, messages, { temperature: 0.5 });
|
|
177
|
+
const body = mocks.anthropicCreate.mock.calls[0][0];
|
|
178
|
+
(0, vitest_1.expect)(body).toHaveProperty('temperature', 0.5);
|
|
179
|
+
});
|
|
180
|
+
vitest_1.it.each(['claude-opus-4-7', 'claude-opus-4-7-20260101'])('complete: omits temperature for unsupported model %s', async (model) => {
|
|
181
|
+
mockCompleteResponse();
|
|
182
|
+
const client = new ai_client_1.AIClient('anthropic', 'sk-anthropic-test');
|
|
183
|
+
await client.complete(model, messages, { temperature: 0.5 });
|
|
184
|
+
const body = mocks.anthropicCreate.mock.calls[0][0];
|
|
185
|
+
(0, vitest_1.expect)(body).not.toHaveProperty('temperature');
|
|
186
|
+
});
|
|
187
|
+
(0, vitest_1.it)('streamComplete: passes temperature for claude-sonnet-4-5', async () => {
|
|
188
|
+
mockStreamResponse();
|
|
189
|
+
const client = new ai_client_1.AIClient('anthropic', 'sk-anthropic-test');
|
|
190
|
+
await client.streamComplete('claude-sonnet-4-5', messages, { temperature: 0.6 }, () => { });
|
|
191
|
+
const body = mocks.anthropicStream.mock.calls[0][0];
|
|
192
|
+
(0, vitest_1.expect)(body).toHaveProperty('temperature', 0.6);
|
|
193
|
+
});
|
|
194
|
+
(0, vitest_1.it)('streamComplete: omits temperature for claude-opus-4-7', async () => {
|
|
195
|
+
mockStreamResponse();
|
|
196
|
+
const client = new ai_client_1.AIClient('anthropic', 'sk-anthropic-test');
|
|
197
|
+
await client.streamComplete('claude-opus-4-7', messages, { temperature: 0.6 }, () => { });
|
|
198
|
+
const body = mocks.anthropicStream.mock.calls[0][0];
|
|
199
|
+
(0, vitest_1.expect)(body).not.toHaveProperty('temperature');
|
|
200
|
+
});
|
|
201
|
+
});
|
|
202
|
+
// ---------------------------------------------------------------------------
|
|
203
|
+
// Gemini
|
|
204
|
+
// ---------------------------------------------------------------------------
|
|
205
|
+
(0, vitest_1.describe)('GeminiAdapter temperature handling', () => {
|
|
206
|
+
function mockCompleteResponse() {
|
|
207
|
+
mocks.geminiGenerate.mockResolvedValueOnce({
|
|
208
|
+
candidates: [
|
|
209
|
+
{
|
|
210
|
+
content: { parts: [{ text: 'ok' }] },
|
|
211
|
+
finishReason: 'STOP',
|
|
212
|
+
},
|
|
213
|
+
],
|
|
214
|
+
usageMetadata: { promptTokenCount: 1, candidatesTokenCount: 1, totalTokenCount: 2 },
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
function mockStreamResponse() {
|
|
218
|
+
mocks.geminiGenerateStream.mockResolvedValueOnce(asAsyncIterable([
|
|
219
|
+
{ text: 'ok' },
|
|
220
|
+
{
|
|
221
|
+
text: '',
|
|
222
|
+
usageMetadata: { promptTokenCount: 1, candidatesTokenCount: 1, totalTokenCount: 2 },
|
|
223
|
+
},
|
|
224
|
+
]));
|
|
225
|
+
}
|
|
226
|
+
vitest_1.it.each(['gemini-2.5-flash', 'gemini-2.5-pro', 'gemini-3-pro'])('complete: passes temperature for %s (all Gemini models accept it)', async (model) => {
|
|
227
|
+
mockCompleteResponse();
|
|
228
|
+
const client = new ai_client_1.AIClient('gemini', 'gemini-test-key');
|
|
229
|
+
await client.complete(model, messages, { temperature: 0.42 });
|
|
230
|
+
const body = mocks.geminiGenerate.mock.calls[0][0];
|
|
231
|
+
(0, vitest_1.expect)(body.config).toHaveProperty('temperature', 0.42);
|
|
232
|
+
});
|
|
233
|
+
(0, vitest_1.it)('streamComplete: passes temperature for gemini-2.5-pro', async () => {
|
|
234
|
+
mockStreamResponse();
|
|
235
|
+
const client = new ai_client_1.AIClient('gemini', 'gemini-test-key');
|
|
236
|
+
await client.streamComplete('gemini-2.5-pro', messages, { temperature: 0.31 }, () => { });
|
|
237
|
+
const body = mocks.geminiGenerateStream.mock.calls[0][0];
|
|
238
|
+
(0, vitest_1.expect)(body.config).toHaveProperty('temperature', 0.31);
|
|
239
|
+
});
|
|
240
|
+
(0, vitest_1.it)('streamComplete: applies default 0.3 when caller omits temperature', async () => {
|
|
241
|
+
mockStreamResponse();
|
|
242
|
+
const client = new ai_client_1.AIClient('gemini', 'gemini-test-key');
|
|
243
|
+
await client.streamComplete('gemini-2.5-pro', messages, {}, () => { });
|
|
244
|
+
const body = mocks.geminiGenerateStream.mock.calls[0][0];
|
|
245
|
+
(0, vitest_1.expect)(body.config).toHaveProperty('temperature', 0.3);
|
|
246
|
+
});
|
|
247
|
+
});
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const vitest_1 = require("vitest");
|
|
4
|
+
const ai_client_1 = require("../ai-client");
|
|
5
|
+
(0, vitest_1.describe)('modelSupportsTemperature', () => {
|
|
6
|
+
(0, vitest_1.describe)('OpenAI', () => {
|
|
7
|
+
vitest_1.it.each([
|
|
8
|
+
['gpt-4o-mini', true],
|
|
9
|
+
['gpt-4o', true],
|
|
10
|
+
['gpt-4-turbo', true],
|
|
11
|
+
['gpt-3.5-turbo', true],
|
|
12
|
+
])('allows temperature for %s', (model, expected) => {
|
|
13
|
+
(0, vitest_1.expect)((0, ai_client_1.modelSupportsTemperature)(model)).toBe(expected);
|
|
14
|
+
});
|
|
15
|
+
vitest_1.it.each([
|
|
16
|
+
['gpt-5', false],
|
|
17
|
+
['gpt-5-mini', false],
|
|
18
|
+
['gpt-5.1', false],
|
|
19
|
+
['gpt-5.5', false],
|
|
20
|
+
['GPT-5.5', false], // case-insensitive
|
|
21
|
+
])('rejects temperature for GPT-5 family member %s', (model, expected) => {
|
|
22
|
+
(0, vitest_1.expect)((0, ai_client_1.modelSupportsTemperature)(model)).toBe(expected);
|
|
23
|
+
});
|
|
24
|
+
vitest_1.it.each([
|
|
25
|
+
['o1', false],
|
|
26
|
+
['o1-preview', false],
|
|
27
|
+
['o3', false],
|
|
28
|
+
['o3-mini', false],
|
|
29
|
+
['o4-mini', false],
|
|
30
|
+
])('rejects temperature for reasoning model %s', (model, expected) => {
|
|
31
|
+
(0, vitest_1.expect)((0, ai_client_1.modelSupportsTemperature)(model)).toBe(expected);
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
(0, vitest_1.describe)('Gemini', () => {
|
|
35
|
+
vitest_1.it.each([
|
|
36
|
+
['gemini-2.5-flash', true],
|
|
37
|
+
['gemini-2.5-pro', true],
|
|
38
|
+
['gemini-3-pro', true],
|
|
39
|
+
['gemini-3.5-flash', true],
|
|
40
|
+
])('allows temperature for %s', (model, expected) => {
|
|
41
|
+
(0, vitest_1.expect)((0, ai_client_1.modelSupportsTemperature)(model)).toBe(expected);
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
(0, vitest_1.describe)('Anthropic', () => {
|
|
45
|
+
vitest_1.it.each([
|
|
46
|
+
['claude-haiku-4-5', true],
|
|
47
|
+
['claude-haiku-4-5-20251001', true],
|
|
48
|
+
['claude-sonnet-4-5', true],
|
|
49
|
+
['claude-sonnet-4-5-20250929', true],
|
|
50
|
+
['claude-opus-4-5', true],
|
|
51
|
+
['claude-opus-4-5-20251101', true],
|
|
52
|
+
['claude-opus-4-6', true],
|
|
53
|
+
])('allows temperature for %s', (model, expected) => {
|
|
54
|
+
(0, vitest_1.expect)((0, ai_client_1.modelSupportsTemperature)(model)).toBe(expected);
|
|
55
|
+
});
|
|
56
|
+
vitest_1.it.each([
|
|
57
|
+
['claude-opus-4-7', false],
|
|
58
|
+
['claude-opus-4-7-20260101', false],
|
|
59
|
+
['CLAUDE-OPUS-4-7', false], // case-insensitive
|
|
60
|
+
])('rejects temperature for opus-4-7 variant %s', (model, expected) => {
|
|
61
|
+
(0, vitest_1.expect)((0, ai_client_1.modelSupportsTemperature)(model)).toBe(expected);
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
(0, vitest_1.describe)('getDefaultModel', () => {
|
|
66
|
+
(0, vitest_1.it)('returns the configured fast and smart tiers for each provider', () => {
|
|
67
|
+
// Don't pin exact model strings — they will be upgraded over time. Just
|
|
68
|
+
// assert that each provider returns a non-empty string for both tiers
|
|
69
|
+
// and that fast/smart differ (smart is meant to be a bigger model).
|
|
70
|
+
for (const provider of ['openai', 'gemini', 'anthropic']) {
|
|
71
|
+
const fast = (0, ai_client_1.getDefaultModel)(provider, 'fast');
|
|
72
|
+
const smart = (0, ai_client_1.getDefaultModel)(provider, 'smart');
|
|
73
|
+
(0, vitest_1.expect)(fast).toBeTruthy();
|
|
74
|
+
(0, vitest_1.expect)(smart).toBeTruthy();
|
|
75
|
+
(0, vitest_1.expect)(fast).not.toEqual(smart);
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
(0, vitest_1.it)('returns smart-tier models that are correctly classified by modelSupportsTemperature', () => {
|
|
79
|
+
// Regression guard: whenever a smart model is upgraded, the helper must
|
|
80
|
+
// continue to return the correct policy for it. This test is the single
|
|
81
|
+
// place that ties the two together so an accidental mismatch breaks the
|
|
82
|
+
// suite immediately.
|
|
83
|
+
const expectations = {
|
|
84
|
+
// OpenAI smart tier is in the GPT-5 family → no temperature.
|
|
85
|
+
openai: false,
|
|
86
|
+
// Gemini smart tier accepts temperature.
|
|
87
|
+
gemini: true,
|
|
88
|
+
// Anthropic smart tier is claude-opus-4-7 → no temperature.
|
|
89
|
+
anthropic: false,
|
|
90
|
+
};
|
|
91
|
+
for (const provider of Object.keys(expectations)) {
|
|
92
|
+
const expected = expectations[provider];
|
|
93
|
+
if (expected === null)
|
|
94
|
+
continue;
|
|
95
|
+
const smartModel = (0, ai_client_1.getDefaultModel)(provider, 'smart');
|
|
96
|
+
(0, vitest_1.expect)((0, ai_client_1.modelSupportsTemperature)(smartModel), `${provider} smart model "${smartModel}" should report temperature-support=${expected}`).toBe(expected);
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
});
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Tests for the temperature-handling change in `runEnhancementModel`.
|
|
4
|
+
*
|
|
5
|
+
* - 'enhance' → { temperature: 0.3 }
|
|
6
|
+
* - 'grammar' → { temperature: 0.3 }
|
|
7
|
+
* - 'task' → {} (no temperature; smart-tier model decides for itself)
|
|
8
|
+
*
|
|
9
|
+
* Mocks `./ai-client` and `./models/subscriptionTaskTemplate` so the test
|
|
10
|
+
* stays a pure unit test and never touches the database or any SDK.
|
|
11
|
+
*/
|
|
12
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
13
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
14
|
+
};
|
|
15
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
16
|
+
const vitest_1 = require("vitest");
|
|
17
|
+
const winston_1 = __importDefault(require("winston"));
|
|
18
|
+
const mocks = vitest_1.vi.hoisted(() => ({
|
|
19
|
+
streamComplete: vitest_1.vi.fn(),
|
|
20
|
+
getDefaultModel: vitest_1.vi.fn(),
|
|
21
|
+
findOne: vitest_1.vi.fn(),
|
|
22
|
+
}));
|
|
23
|
+
vitest_1.vi.mock('../ai-client', () => ({
|
|
24
|
+
aiClient: { streamComplete: mocks.streamComplete },
|
|
25
|
+
getDefaultModel: mocks.getDefaultModel,
|
|
26
|
+
}));
|
|
27
|
+
vitest_1.vi.mock('../models/subscriptionTaskTemplate', () => ({
|
|
28
|
+
SubscriptionTaskTemplate: { findOne: mocks.findOne },
|
|
29
|
+
}));
|
|
30
|
+
const featureRoutes_1 = require("../featureRoutes");
|
|
31
|
+
function makeLogger() {
|
|
32
|
+
return winston_1.default.createLogger({
|
|
33
|
+
silent: true,
|
|
34
|
+
transports: [new winston_1.default.transports.Console({ silent: true })],
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
const fakeSubscription = { id: 'sub_test' };
|
|
38
|
+
(0, vitest_1.beforeEach)(() => {
|
|
39
|
+
mocks.streamComplete.mockReset();
|
|
40
|
+
mocks.streamComplete.mockResolvedValue({ usage: undefined, model: 'mock-model' });
|
|
41
|
+
mocks.getDefaultModel.mockReset();
|
|
42
|
+
mocks.getDefaultModel.mockImplementation((_provider, tier) => tier === 'smart' ? 'smart-model-mock' : 'fast-model-mock');
|
|
43
|
+
mocks.findOne.mockReset();
|
|
44
|
+
// Default task template — plain text passes through `decompressString`
|
|
45
|
+
// so `getPromptForCommand('task', ...)` returns a non-empty prompt and the
|
|
46
|
+
// streamComplete path is reached.
|
|
47
|
+
mocks.findOne.mockResolvedValue({ instructions: 'You are a helpful task assistant.' });
|
|
48
|
+
});
|
|
49
|
+
(0, vitest_1.describe)('runEnhancementModel — temperature per command', () => {
|
|
50
|
+
(0, vitest_1.it)("passes temperature: 0.3 for cmd='enhance'", async () => {
|
|
51
|
+
const result = await (0, featureRoutes_1.runEnhancementModel)(makeLogger(), 'hello world', 'enhance', fakeSubscription);
|
|
52
|
+
(0, vitest_1.expect)(result).not.toBeNull();
|
|
53
|
+
(0, vitest_1.expect)(mocks.streamComplete).toHaveBeenCalledTimes(1);
|
|
54
|
+
const [, , options] = mocks.streamComplete.mock.calls[0];
|
|
55
|
+
(0, vitest_1.expect)(options).toEqual({ temperature: 0.3 });
|
|
56
|
+
});
|
|
57
|
+
(0, vitest_1.it)("passes temperature: 0.3 for cmd='grammar'", async () => {
|
|
58
|
+
const result = await (0, featureRoutes_1.runEnhancementModel)(makeLogger(), 'helo wrld', 'grammar', fakeSubscription);
|
|
59
|
+
(0, vitest_1.expect)(result).not.toBeNull();
|
|
60
|
+
(0, vitest_1.expect)(mocks.streamComplete).toHaveBeenCalledTimes(1);
|
|
61
|
+
const [, , options] = mocks.streamComplete.mock.calls[0];
|
|
62
|
+
(0, vitest_1.expect)(options).toEqual({ temperature: 0.3 });
|
|
63
|
+
});
|
|
64
|
+
(0, vitest_1.it)("omits temperature for cmd='task' (custom-task)", async () => {
|
|
65
|
+
const result = await (0, featureRoutes_1.runEnhancementModel)(makeLogger(), 'do the thing', 'task', fakeSubscription);
|
|
66
|
+
(0, vitest_1.expect)(result).not.toBeNull();
|
|
67
|
+
(0, vitest_1.expect)(mocks.streamComplete).toHaveBeenCalledTimes(1);
|
|
68
|
+
const [, , options] = mocks.streamComplete.mock.calls[0];
|
|
69
|
+
(0, vitest_1.expect)(options).toEqual({});
|
|
70
|
+
(0, vitest_1.expect)(options).not.toHaveProperty('temperature');
|
|
71
|
+
});
|
|
72
|
+
(0, vitest_1.it)("selects the smart-tier model for cmd='task' and fast-tier for enhance/grammar", async () => {
|
|
73
|
+
await (0, featureRoutes_1.runEnhancementModel)(makeLogger(), 'a', 'task', fakeSubscription);
|
|
74
|
+
await (0, featureRoutes_1.runEnhancementModel)(makeLogger(), 'b', 'enhance', fakeSubscription);
|
|
75
|
+
await (0, featureRoutes_1.runEnhancementModel)(makeLogger(), 'c', 'grammar', fakeSubscription);
|
|
76
|
+
const modelsCalled = mocks.streamComplete.mock.calls.map(([model]) => model);
|
|
77
|
+
(0, vitest_1.expect)(modelsCalled).toEqual(['smart-model-mock', 'fast-model-mock', 'fast-model-mock']);
|
|
78
|
+
});
|
|
79
|
+
});
|
|
@@ -603,19 +603,19 @@ async function runAgentTurnInternal(sessionId, subscription, clientMessage, send
|
|
|
603
603
|
// is not the final turn (e.g. plain-text conclusion after terminal
|
|
604
604
|
// output). Treat it as a final answer so the client is never left
|
|
605
605
|
// hanging.
|
|
606
|
-
log.info('Agent returned untagged content on a non-final turn; treating as
|
|
606
|
+
log.info('Agent returned untagged content on a non-final turn; treating as assistant response and looping the function again.', {
|
|
607
607
|
sessionId,
|
|
608
608
|
subscriptionId: subscription.id,
|
|
609
609
|
turn: session.turns,
|
|
610
610
|
});
|
|
611
611
|
(0, utils_1.pushToSessionHistory)(log, session, { role: 'assistant', content });
|
|
612
612
|
await persistSessionToDB(sessionId, session);
|
|
613
|
-
|
|
614
|
-
session_id: sessionId,
|
|
613
|
+
await runAgentTurnInternal(sessionId, subscription, {
|
|
615
614
|
sender: 'agent',
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
615
|
+
session_id: sessionId,
|
|
616
|
+
content: '',
|
|
617
|
+
is_web_call: true,
|
|
618
|
+
}, send, logger_1.logger, options);
|
|
619
619
|
}
|
|
620
620
|
else {
|
|
621
621
|
log.warn('Agent returned empty content with no recognized tags; sending error', {
|
|
@@ -797,7 +797,12 @@ function buildTranscript(raw) {
|
|
|
797
797
|
break;
|
|
798
798
|
}
|
|
799
799
|
}
|
|
800
|
-
currentAssistant.text =
|
|
800
|
+
currentAssistant.text =
|
|
801
|
+
finalText ||
|
|
802
|
+
blocks
|
|
803
|
+
.map((b) => b.text)
|
|
804
|
+
.join('\n\n')
|
|
805
|
+
.trim();
|
|
801
806
|
messages.push(currentAssistant);
|
|
802
807
|
currentAssistant = null;
|
|
803
808
|
};
|
|
@@ -5,6 +5,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
6
|
exports.aiClient = exports.AIClient = void 0;
|
|
7
7
|
exports.getDefaultModel = getDefaultModel;
|
|
8
|
+
exports.modelSupportsTemperature = modelSupportsTemperature;
|
|
8
9
|
exports.getMaxMessageContentLength = getMaxMessageContentLength;
|
|
9
10
|
exports.getMaxHistoryLength = getMaxHistoryLength;
|
|
10
11
|
exports.getContextWindowSize = getContextWindowSize;
|
|
@@ -17,6 +18,12 @@ const config_1 = require("./config");
|
|
|
17
18
|
// Default model mapping
|
|
18
19
|
// ---------------------------------------------------------------------------
|
|
19
20
|
const DEFAULT_MODELS = {
|
|
21
|
+
// Smart-tier picks track each provider's current flagship for
|
|
22
|
+
// reasoning/coding workloads. Update here when a newer model becomes
|
|
23
|
+
// generally available so both the feature routes and the agent server pick
|
|
24
|
+
// it up automatically. When swapping a smart model in, also verify whether
|
|
25
|
+
// it accepts the `temperature` parameter and update
|
|
26
|
+
// `modelSupportsTemperature` accordingly.
|
|
20
27
|
openai: { fast: 'gpt-4o-mini', smart: 'gpt-5.5' },
|
|
21
28
|
gemini: { fast: 'gemini-2.5-flash', smart: 'gemini-2.5-pro' },
|
|
22
29
|
anthropic: { fast: 'claude-haiku-4-5-20251001', smart: 'claude-opus-4-7' },
|
|
@@ -24,6 +31,39 @@ const DEFAULT_MODELS = {
|
|
|
24
31
|
function getDefaultModel(provider, tier) {
|
|
25
32
|
return DEFAULT_MODELS[provider][tier];
|
|
26
33
|
}
|
|
34
|
+
/**
|
|
35
|
+
* Returns whether a given model accepts the `temperature` parameter.
|
|
36
|
+
*
|
|
37
|
+
* Provider-specific rules (validated against published API docs and SDKs as
|
|
38
|
+
* of late 2025 / early 2026):
|
|
39
|
+
* - OpenAI GPT-5 family (`gpt-5`, `gpt-5-mini`, `gpt-5.1`, …): NOT supported.
|
|
40
|
+
* The API only accepts the default value (1) and returns
|
|
41
|
+
* `unsupported_value: 'temperature'` for anything else.
|
|
42
|
+
* - OpenAI o-series reasoning models (`o1`, `o3`, `o4-mini`, …): NOT
|
|
43
|
+
* supported for the same reason.
|
|
44
|
+
* - OpenAI GPT-4 / GPT-4o / GPT-3.5: supported.
|
|
45
|
+
* - Google Gemini (2.x and 3.x families): supported via `generationConfig`.
|
|
46
|
+
* - Anthropic Claude (Sonnet, Haiku, and Opus 4.x): supported, with the
|
|
47
|
+
* exception of `claude-opus-4-7` (and its dated revisions) which rejects
|
|
48
|
+
* `temperature` just like the OpenAI GPT-5 family.
|
|
49
|
+
*/
|
|
50
|
+
function modelSupportsTemperature(model) {
|
|
51
|
+
// OpenAI GPT-5 family (gpt-5, gpt-5-mini, gpt-5.1, gpt-5.5, …) only
|
|
52
|
+
// accepts the default temperature (1) — anything else is rejected with
|
|
53
|
+
// `unsupported_value: 'temperature'`.
|
|
54
|
+
if (/^gpt-5(\b|[.\-])/i.test(model))
|
|
55
|
+
return false;
|
|
56
|
+
// OpenAI o-series reasoning models (o1, o3, o4-mini, …) likewise drop the
|
|
57
|
+
// `temperature` knob.
|
|
58
|
+
if (/^o[134](\b|[-_])/i.test(model))
|
|
59
|
+
return false;
|
|
60
|
+
// Anthropic's Claude Opus 4.7 line (and its dated revisions like
|
|
61
|
+
// `claude-opus-4-7-20260101`) does not accept `temperature`; the rest of
|
|
62
|
+
// the Claude 4.x family (Sonnet, Haiku, Opus 4.5/4.6) does.
|
|
63
|
+
if (/^claude-opus-4-7(\b|[-_])/i.test(model))
|
|
64
|
+
return false;
|
|
65
|
+
return true;
|
|
66
|
+
}
|
|
27
67
|
/**
|
|
28
68
|
* Maximum character length for a single message content string per provider.
|
|
29
69
|
*
|
|
@@ -88,7 +128,7 @@ class OpenAIAdapter {
|
|
|
88
128
|
model,
|
|
89
129
|
messages: oaiMessages,
|
|
90
130
|
tools: tools?.length ? tools : undefined,
|
|
91
|
-
|
|
131
|
+
...(modelSupportsTemperature(model) ? { temperature: options.temperature ?? 0.2 } : {}),
|
|
92
132
|
max_tokens: options.maxTokens,
|
|
93
133
|
});
|
|
94
134
|
const choice = completion.choices[0];
|
|
@@ -125,7 +165,9 @@ class OpenAIAdapter {
|
|
|
125
165
|
const stream = await this.client.chat.completions.create({
|
|
126
166
|
model,
|
|
127
167
|
messages: oaiMessages,
|
|
128
|
-
|
|
168
|
+
...(modelSupportsTemperature(model)
|
|
169
|
+
? { temperature: options.temperature ?? 0.3 }
|
|
170
|
+
: {}),
|
|
129
171
|
stream: true,
|
|
130
172
|
stream_options: { include_usage: true },
|
|
131
173
|
});
|
|
@@ -188,7 +230,7 @@ class AnthropicAdapter {
|
|
|
188
230
|
...(system ? { system } : {}),
|
|
189
231
|
messages: anthropicMessages,
|
|
190
232
|
...(tools?.length ? { tools } : {}),
|
|
191
|
-
...(model
|
|
233
|
+
...(modelSupportsTemperature(model) ? { temperature: options.temperature ?? 0.2 } : {}),
|
|
192
234
|
});
|
|
193
235
|
const textContent = response.content
|
|
194
236
|
.filter((b) => b.type === 'text')
|
|
@@ -235,7 +277,9 @@ class AnthropicAdapter {
|
|
|
235
277
|
max_tokens: options.maxTokens ?? 8192,
|
|
236
278
|
...(system ? { system } : {}),
|
|
237
279
|
messages: anthropicMessages,
|
|
238
|
-
|
|
280
|
+
...(modelSupportsTemperature(model)
|
|
281
|
+
? { temperature: options.temperature ?? 0.3 }
|
|
282
|
+
: {}),
|
|
239
283
|
});
|
|
240
284
|
for await (const event of stream) {
|
|
241
285
|
if (event.type === 'content_block_delta' &&
|
|
@@ -269,7 +313,9 @@ class GeminiAdapter {
|
|
|
269
313
|
config: {
|
|
270
314
|
...(systemInstruction ? { systemInstruction } : {}),
|
|
271
315
|
...(tools?.length ? { tools } : {}),
|
|
272
|
-
|
|
316
|
+
...(modelSupportsTemperature(model)
|
|
317
|
+
? { temperature: options.temperature ?? 0.2 }
|
|
318
|
+
: {}),
|
|
273
319
|
},
|
|
274
320
|
});
|
|
275
321
|
const candidate = response.candidates?.[0];
|
|
@@ -320,7 +366,9 @@ class GeminiAdapter {
|
|
|
320
366
|
contents,
|
|
321
367
|
config: {
|
|
322
368
|
...(systemInstruction ? { systemInstruction } : {}),
|
|
323
|
-
|
|
369
|
+
...(modelSupportsTemperature(model)
|
|
370
|
+
? { temperature: options.temperature ?? 0.3 }
|
|
371
|
+
: {}),
|
|
324
372
|
},
|
|
325
373
|
});
|
|
326
374
|
let usage;
|
package/backend-dist/db.js
CHANGED
|
@@ -44,6 +44,68 @@ async function runSQLiteMigrations(logger) {
|
|
|
44
44
|
logger.info(`SQLite migration: added column ${table}.${column}`);
|
|
45
45
|
}
|
|
46
46
|
}
|
|
47
|
+
// mcp_servers was originally created with UNIQUE on both subscription_id and
|
|
48
|
+
// name as column-level constraints (SQLite auto-indexes). These can't be
|
|
49
|
+
// dropped with DROP INDEX — the only fix is to recreate the table with the
|
|
50
|
+
// correct schema (composite unique on subscription_id+name only).
|
|
51
|
+
await migrateMcpServersTableIfNeeded(logger);
|
|
52
|
+
}
|
|
53
|
+
async function migrateMcpServersTableIfNeeded(logger) {
|
|
54
|
+
// Check if the old schema is still in place by inspecting the CREATE TABLE sql.
|
|
55
|
+
const rows = (await sequelize.query(`SELECT sql FROM sqlite_master WHERE type='table' AND name='mcp_servers'`))[0];
|
|
56
|
+
if (!rows.length)
|
|
57
|
+
return; // table doesn't exist yet — sync() will create it correctly
|
|
58
|
+
const createSql = rows[0].sql;
|
|
59
|
+
// Old schema has UNIQUE on subscription_id at the column level.
|
|
60
|
+
// New schema only has the composite index mcp_servers_subscription_id_name.
|
|
61
|
+
const needsMigration = /`subscription_id`[^,]*UNIQUE/i.test(createSql);
|
|
62
|
+
if (!needsMigration)
|
|
63
|
+
return;
|
|
64
|
+
logger.info('SQLite migration: recreating mcp_servers table to remove stale UNIQUE constraints');
|
|
65
|
+
await sequelize.query('PRAGMA foreign_keys = OFF');
|
|
66
|
+
try {
|
|
67
|
+
await sequelize.query('BEGIN TRANSACTION');
|
|
68
|
+
await sequelize.query(`
|
|
69
|
+
CREATE TABLE \`mcp_servers_new\` (
|
|
70
|
+
\`id\` VARCHAR(255) NOT NULL PRIMARY KEY,
|
|
71
|
+
\`subscription_id\` VARCHAR(255) NOT NULL REFERENCES \`subscriptions\` (\`id\`) ON DELETE CASCADE ON UPDATE CASCADE,
|
|
72
|
+
\`name\` VARCHAR(100) NOT NULL,
|
|
73
|
+
\`description\` VARCHAR(500),
|
|
74
|
+
\`transport\` VARCHAR(16) NOT NULL DEFAULT 'stdio',
|
|
75
|
+
\`command\` VARCHAR(500),
|
|
76
|
+
\`args\` JSON NOT NULL DEFAULT '[]',
|
|
77
|
+
\`env\` JSON NOT NULL DEFAULT '{}',
|
|
78
|
+
\`url\` VARCHAR(1000),
|
|
79
|
+
\`headers\` JSON NOT NULL DEFAULT '{}',
|
|
80
|
+
\`is_enabled\` TINYINT(1) NOT NULL DEFAULT 1,
|
|
81
|
+
\`last_connected_at\` DATETIME,
|
|
82
|
+
\`last_error\` TEXT,
|
|
83
|
+
\`createdAt\` DATETIME NOT NULL,
|
|
84
|
+
\`updatedAt\` DATETIME NOT NULL
|
|
85
|
+
)
|
|
86
|
+
`);
|
|
87
|
+
await sequelize.query(`
|
|
88
|
+
INSERT INTO \`mcp_servers_new\`
|
|
89
|
+
SELECT id, subscription_id, name, description, transport, command, args, env,
|
|
90
|
+
url, headers, is_enabled, last_connected_at, last_error, createdAt, updatedAt
|
|
91
|
+
FROM \`mcp_servers\`
|
|
92
|
+
`);
|
|
93
|
+
await sequelize.query('DROP TABLE `mcp_servers`');
|
|
94
|
+
await sequelize.query('ALTER TABLE `mcp_servers_new` RENAME TO `mcp_servers`');
|
|
95
|
+
await sequelize.query(`
|
|
96
|
+
CREATE UNIQUE INDEX IF NOT EXISTS \`mcp_servers_subscription_id_name\`
|
|
97
|
+
ON \`mcp_servers\` (\`subscription_id\`, \`name\`)
|
|
98
|
+
`);
|
|
99
|
+
await sequelize.query('COMMIT');
|
|
100
|
+
logger.info('SQLite migration: mcp_servers table recreated successfully');
|
|
101
|
+
}
|
|
102
|
+
catch (err) {
|
|
103
|
+
await sequelize.query('ROLLBACK');
|
|
104
|
+
throw err;
|
|
105
|
+
}
|
|
106
|
+
finally {
|
|
107
|
+
await sequelize.query('PRAGMA foreign_keys = ON');
|
|
108
|
+
}
|
|
47
109
|
}
|
|
48
110
|
async function initDatabase(logger) {
|
|
49
111
|
try {
|
|
@@ -56,13 +56,12 @@ async function getPromptForCommand(logger, cmd, subscription) {
|
|
|
56
56
|
return '';
|
|
57
57
|
}
|
|
58
58
|
function getModelForCommand(cmd) {
|
|
59
|
+
// 'task' is the custom-task command and routes to the smart-tier model.
|
|
60
|
+
// 'enhance' and 'grammar' use the fast tier. The actual model strings live
|
|
61
|
+
// in ai-client.ts (DEFAULT_MODELS) so all callers stay in sync when we
|
|
62
|
+
// upgrade to a newer flagship model.
|
|
59
63
|
const tier = cmd === 'task' ? 'smart' : 'fast';
|
|
60
|
-
|
|
61
|
-
openai: { fast: 'gpt-4o-mini', smart: 'gpt-5.5' },
|
|
62
|
-
gemini: { fast: 'gemini-2.5-flash', smart: 'gemini-2.5-pro' },
|
|
63
|
-
anthropic: { fast: 'claude-haiku-4-5-20251001', smart: 'claude-opus-4-7' },
|
|
64
|
-
};
|
|
65
|
-
return models[config_1.config.aiProvider]?.[tier] ?? 'gpt-4o-mini';
|
|
64
|
+
return (0, ai_client_1.getDefaultModel)(config_1.config.aiProvider, tier);
|
|
66
65
|
}
|
|
67
66
|
function createMessagesParams(cmd, input, prompt) {
|
|
68
67
|
if (cmd === 'task') {
|
|
@@ -100,7 +99,14 @@ async function runEnhancementModel(logger, text, cmd, subscription, onDelta) {
|
|
|
100
99
|
const messages = createMessagesParams(cmd, trimmed, prompt);
|
|
101
100
|
let rawResponse = '';
|
|
102
101
|
let usage;
|
|
103
|
-
|
|
102
|
+
// Smart-tier models (used by the custom-task command) include OpenAI's
|
|
103
|
+
// GPT-5 family, which rejects any non-default `temperature`. Even on
|
|
104
|
+
// providers where the smart model still accepts it (Gemini, Anthropic),
|
|
105
|
+
// omitting `temperature` keeps the request shape uniform across providers
|
|
106
|
+
// and lets each model use its own tuned default. The fast-tier models used
|
|
107
|
+
// by `enhance` and `grammar` keep the previous 0.3 default.
|
|
108
|
+
const completionOptions = cmd === 'task' ? {} : { temperature: 0.3 };
|
|
109
|
+
const result = await ai_client_1.aiClient.streamComplete(model, messages, completionOptions, (delta) => {
|
|
104
110
|
rawResponse += delta;
|
|
105
111
|
if (onDelta)
|
|
106
112
|
onDelta(delta);
|
package/backend-dist/index.js
CHANGED
|
@@ -107,7 +107,7 @@ app.get('/macos/appcast', (req, res) => {
|
|
|
107
107
|
// ── Windows distribution endpoints ───────────────────────────────────────────
|
|
108
108
|
// These should match the values in windows/OmniKey.Windows.csproj
|
|
109
109
|
// <Version> and windows/build_release_zip.ps1 $APP_VERSION.
|
|
110
|
-
const WIN_VERSION = '1.
|
|
110
|
+
const WIN_VERSION = '1.13';
|
|
111
111
|
const WIN_ZIP_FILENAME = 'OmniKeyAI-windows-win-x64.zip';
|
|
112
112
|
const WIN_ZIP_PATH = path_1.default.join(process.cwd(), 'windows', WIN_ZIP_FILENAME);
|
|
113
113
|
// Serves the pre-built ZIP produced by windows/build_release_zip.ps1.
|
|
@@ -152,15 +152,19 @@ app.get('/windows/update', (req, res) => {
|
|
|
152
152
|
releaseNotes: [
|
|
153
153
|
`What's new in ${WIN_VERSION}`,
|
|
154
154
|
``,
|
|
155
|
-
`•
|
|
156
|
-
`•
|
|
157
|
-
`•
|
|
158
|
-
`•
|
|
159
|
-
`•
|
|
160
|
-
`•
|
|
161
|
-
`•
|
|
162
|
-
`•
|
|
163
|
-
`•
|
|
155
|
+
`• Projects: chats are now grouped by project in the sidebar — collapsible "folder" headers per group, a session count badge, and per-header collapse state that survives streaming turns.`,
|
|
156
|
+
`• Projects: new project picker in the composer toolbar (next to the task-instruction selector) — pick the project for your next turn, mirrors the macOS "Select project" menu. Auto-hides until the backend has classified at least one group.`,
|
|
157
|
+
`• Projects: the chosen project is stamped onto the outbound message and the optimistic session placeholder, so new chats appear under the right header immediately.`,
|
|
158
|
+
`• Chat: messages now sit in a centered 820 DIP reading column on large monitors and stretch edge-to-edge on smaller windows — matches the macOS layout exactly. User bubbles stay pinned right, assistant content stays pinned left, on every viewport.`,
|
|
159
|
+
`• Chat: new Final Answer card with a soft "paper" surface (mirrors macOS), copy button anchored bottom-right so it no longer overlaps long markdown headings, and an "Answer" tooltip on copy.`,
|
|
160
|
+
`• Chat: animated typing indicator (pulsing sparkle + three staggered dots) appears the moment you send your first message — matches macOS TypingDotsView.`,
|
|
161
|
+
`• Chat: extra breathing room between thinking-timeline steps so the agent's intermediate reasoning reads as discrete actions instead of a cramped wall.`,
|
|
162
|
+
`• Markdown: brand-new Nord-themed renderer — no more white-background leaks from the underlying MdXaml engine on paragraphs, blockquotes, lists, tables, or inline code.`,
|
|
163
|
+
`• Markdown: bullets and numbered lists are no longer clipped on the left edge.`,
|
|
164
|
+
`• Markdown: inline code now renders as a soft pill (BadgeFill) instead of a dark slab; fenced code blocks keep their rounded macOS-style chrome with language label + copy.`,
|
|
165
|
+
`• MCP Servers: editor now supports custom HTTP headers — one Key: Value per line, monospace input, persisted alongside the URL. Authorization headers are unredacted on edit so they round-trip cleanly, and stale fetches won't clobber what you're typing.`,
|
|
166
|
+
`• Composer: capped + centered at 820 DIP on wide monitors for a balanced layout, full pane width on smaller windows.`,
|
|
167
|
+
`• Theme: shared interactive-surface brushes (Hover, Press, CodeBackground, UserBubble, AssistantText, DangerSoft, FinalAnswerSurface, BadgeFill) promoted to NordTheme.xaml so every page stays in visual lockstep.`,
|
|
164
168
|
].join('\n'),
|
|
165
169
|
});
|
|
166
170
|
});
|
|
@@ -122,7 +122,12 @@ function mcpServerRouter() {
|
|
|
122
122
|
return res.status(400).json({ error: 'Invalid MCP server data.' });
|
|
123
123
|
}
|
|
124
124
|
if (err?.name === 'SequelizeUniqueConstraintError') {
|
|
125
|
-
|
|
125
|
+
const isNameConflict = err?.fields?.includes('name') || err?.errors?.some((e) => e.path === 'name');
|
|
126
|
+
return res.status(409).json({
|
|
127
|
+
error: isNameConflict
|
|
128
|
+
? 'An MCP server with that name already exists.'
|
|
129
|
+
: 'Failed to create MCP server due to a conflict.',
|
|
130
|
+
});
|
|
126
131
|
}
|
|
127
132
|
res.status(500).json({ error: 'Failed to create MCP server.' });
|
|
128
133
|
}
|
package/package.json
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
"access": "public",
|
|
5
5
|
"registry": "https://registry.npmjs.org/"
|
|
6
6
|
},
|
|
7
|
-
"version": "1.
|
|
7
|
+
"version": "1.3.0",
|
|
8
8
|
"description": "CLI for onboarding users to Omnikey AI and configuring OPENAI_API_KEY. Use Yarn for install/build.",
|
|
9
9
|
"engines": {
|
|
10
10
|
"node": ">=14.0.0",
|