@itphx/synth-core 0.3.0 → 0.4.4

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.
Files changed (187) hide show
  1. package/dist/auth/local.d.ts +14 -0
  2. package/dist/auth/local.d.ts.map +1 -1
  3. package/dist/auth/local.js +63 -11
  4. package/dist/auth/local.js.map +1 -1
  5. package/dist/auth/local.test.js +59 -0
  6. package/dist/auth/local.test.js.map +1 -1
  7. package/dist/events/bus.d.ts +7 -0
  8. package/dist/events/bus.d.ts.map +1 -1
  9. package/dist/events/bus.js +26 -3
  10. package/dist/events/bus.js.map +1 -1
  11. package/dist/events/bus.test.js +46 -0
  12. package/dist/events/bus.test.js.map +1 -1
  13. package/dist/index.d.ts +3 -1
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +2 -1
  16. package/dist/index.js.map +1 -1
  17. package/dist/main.js +247 -8
  18. package/dist/main.js.map +1 -1
  19. package/dist/providers/index.d.ts +1 -0
  20. package/dist/providers/index.d.ts.map +1 -1
  21. package/dist/providers/index.js +4 -0
  22. package/dist/providers/index.js.map +1 -1
  23. package/dist/providers/remote.d.ts +60 -0
  24. package/dist/providers/remote.d.ts.map +1 -0
  25. package/dist/providers/remote.js +394 -0
  26. package/dist/providers/remote.js.map +1 -0
  27. package/dist/providers/remote.test.d.ts +2 -0
  28. package/dist/providers/remote.test.d.ts.map +1 -0
  29. package/dist/providers/remote.test.js +258 -0
  30. package/dist/providers/remote.test.js.map +1 -0
  31. package/dist/remote/aggregate.d.ts +43 -0
  32. package/dist/remote/aggregate.d.ts.map +1 -0
  33. package/dist/remote/aggregate.js +84 -0
  34. package/dist/remote/aggregate.js.map +1 -0
  35. package/dist/remote/aggregate.test.d.ts +2 -0
  36. package/dist/remote/aggregate.test.d.ts.map +1 -0
  37. package/dist/remote/aggregate.test.js +120 -0
  38. package/dist/remote/aggregate.test.js.map +1 -0
  39. package/dist/remote/client.d.ts +142 -0
  40. package/dist/remote/client.d.ts.map +1 -0
  41. package/dist/remote/client.js +290 -0
  42. package/dist/remote/client.js.map +1 -0
  43. package/dist/remote/client.test.d.ts +2 -0
  44. package/dist/remote/client.test.d.ts.map +1 -0
  45. package/dist/remote/client.test.js +176 -0
  46. package/dist/remote/client.test.js.map +1 -0
  47. package/dist/remote/connection.d.ts +66 -0
  48. package/dist/remote/connection.d.ts.map +1 -0
  49. package/dist/remote/connection.js +298 -0
  50. package/dist/remote/connection.js.map +1 -0
  51. package/dist/remote/connection.test.d.ts +2 -0
  52. package/dist/remote/connection.test.d.ts.map +1 -0
  53. package/dist/remote/connection.test.js +323 -0
  54. package/dist/remote/connection.test.js.map +1 -0
  55. package/dist/remote/credentials.d.ts +13 -0
  56. package/dist/remote/credentials.d.ts.map +1 -0
  57. package/dist/remote/credentials.js +81 -0
  58. package/dist/remote/credentials.js.map +1 -0
  59. package/dist/remote/credentials.test.d.ts +2 -0
  60. package/dist/remote/credentials.test.d.ts.map +1 -0
  61. package/dist/remote/credentials.test.js +69 -0
  62. package/dist/remote/credentials.test.js.map +1 -0
  63. package/dist/remote/device-flow.d.ts +72 -0
  64. package/dist/remote/device-flow.d.ts.map +1 -0
  65. package/dist/remote/device-flow.js +229 -0
  66. package/dist/remote/device-flow.js.map +1 -0
  67. package/dist/remote/device-flow.test.d.ts +2 -0
  68. package/dist/remote/device-flow.test.d.ts.map +1 -0
  69. package/dist/remote/device-flow.test.js +103 -0
  70. package/dist/remote/device-flow.test.js.map +1 -0
  71. package/dist/remote/event-bridge.d.ts +46 -0
  72. package/dist/remote/event-bridge.d.ts.map +1 -0
  73. package/dist/remote/event-bridge.js +218 -0
  74. package/dist/remote/event-bridge.js.map +1 -0
  75. package/dist/remote/event-bridge.test.d.ts +2 -0
  76. package/dist/remote/event-bridge.test.d.ts.map +1 -0
  77. package/dist/remote/event-bridge.test.js +240 -0
  78. package/dist/remote/event-bridge.test.js.map +1 -0
  79. package/dist/remote/index.d.ts +19 -0
  80. package/dist/remote/index.d.ts.map +1 -0
  81. package/dist/remote/index.js +11 -0
  82. package/dist/remote/index.js.map +1 -0
  83. package/dist/remote/session.d.ts +72 -0
  84. package/dist/remote/session.d.ts.map +1 -0
  85. package/dist/remote/session.js +248 -0
  86. package/dist/remote/session.js.map +1 -0
  87. package/dist/remote/session.test.d.ts +2 -0
  88. package/dist/remote/session.test.d.ts.map +1 -0
  89. package/dist/remote/session.test.js +482 -0
  90. package/dist/remote/session.test.js.map +1 -0
  91. package/dist/remote/transfer.d.ts +75 -0
  92. package/dist/remote/transfer.d.ts.map +1 -0
  93. package/dist/remote/transfer.js +491 -0
  94. package/dist/remote/transfer.js.map +1 -0
  95. package/dist/remote/transfer.test.d.ts +2 -0
  96. package/dist/remote/transfer.test.d.ts.map +1 -0
  97. package/dist/remote/transfer.test.js +416 -0
  98. package/dist/remote/transfer.test.js.map +1 -0
  99. package/dist/remote/types.d.ts +28 -0
  100. package/dist/remote/types.d.ts.map +1 -0
  101. package/dist/remote/types.js +10 -0
  102. package/dist/remote/types.js.map +1 -0
  103. package/dist/server/app.d.ts +62 -1
  104. package/dist/server/app.d.ts.map +1 -1
  105. package/dist/server/app.js +364 -20
  106. package/dist/server/app.js.map +1 -1
  107. package/dist/server/app.proxy.test.d.ts +2 -0
  108. package/dist/server/app.proxy.test.d.ts.map +1 -0
  109. package/dist/server/app.proxy.test.js +193 -0
  110. package/dist/server/app.proxy.test.js.map +1 -0
  111. package/dist/server/app.remote.test.d.ts +2 -0
  112. package/dist/server/app.remote.test.d.ts.map +1 -0
  113. package/dist/server/app.remote.test.js +674 -0
  114. package/dist/server/app.remote.test.js.map +1 -0
  115. package/dist/server/proxy.d.ts +14 -0
  116. package/dist/server/proxy.d.ts.map +1 -0
  117. package/dist/server/proxy.js +93 -0
  118. package/dist/server/proxy.js.map +1 -0
  119. package/dist/server/routes/auth.d.ts +26 -2
  120. package/dist/server/routes/auth.d.ts.map +1 -1
  121. package/dist/server/routes/auth.js +269 -36
  122. package/dist/server/routes/auth.js.map +1 -1
  123. package/dist/server/routes/auth.test.d.ts +2 -0
  124. package/dist/server/routes/auth.test.d.ts.map +1 -0
  125. package/dist/server/routes/auth.test.js +466 -0
  126. package/dist/server/routes/auth.test.js.map +1 -0
  127. package/dist/server/routes/fs.d.ts +2 -1
  128. package/dist/server/routes/fs.d.ts.map +1 -1
  129. package/dist/server/routes/fs.js +159 -5
  130. package/dist/server/routes/fs.js.map +1 -1
  131. package/dist/server/routes/hybrid.d.ts +32 -0
  132. package/dist/server/routes/hybrid.d.ts.map +1 -0
  133. package/dist/server/routes/hybrid.js +21 -0
  134. package/dist/server/routes/hybrid.js.map +1 -0
  135. package/dist/server/routes/hybrid.test.d.ts +2 -0
  136. package/dist/server/routes/hybrid.test.d.ts.map +1 -0
  137. package/dist/server/routes/hybrid.test.js +454 -0
  138. package/dist/server/routes/hybrid.test.js.map +1 -0
  139. package/dist/server/routes/messages.d.ts +2 -1
  140. package/dist/server/routes/messages.d.ts.map +1 -1
  141. package/dist/server/routes/messages.js +104 -1
  142. package/dist/server/routes/messages.js.map +1 -1
  143. package/dist/server/routes/models.cache.test.d.ts +2 -0
  144. package/dist/server/routes/models.cache.test.d.ts.map +1 -0
  145. package/dist/server/routes/models.cache.test.js +59 -0
  146. package/dist/server/routes/models.cache.test.js.map +1 -0
  147. package/dist/server/routes/models.d.ts +9 -1
  148. package/dist/server/routes/models.d.ts.map +1 -1
  149. package/dist/server/routes/models.js +35 -2
  150. package/dist/server/routes/models.js.map +1 -1
  151. package/dist/server/routes/models.remote.test.d.ts +2 -0
  152. package/dist/server/routes/models.remote.test.d.ts.map +1 -0
  153. package/dist/server/routes/models.remote.test.js +101 -0
  154. package/dist/server/routes/models.remote.test.js.map +1 -0
  155. package/dist/server/routes/project-transfer.d.ts +16 -0
  156. package/dist/server/routes/project-transfer.d.ts.map +1 -0
  157. package/dist/server/routes/project-transfer.js +141 -0
  158. package/dist/server/routes/project-transfer.js.map +1 -0
  159. package/dist/server/routes/project-transfer.test.d.ts +2 -0
  160. package/dist/server/routes/project-transfer.test.d.ts.map +1 -0
  161. package/dist/server/routes/project-transfer.test.js +199 -0
  162. package/dist/server/routes/project-transfer.test.js.map +1 -0
  163. package/dist/server/routes/projects.d.ts +2 -1
  164. package/dist/server/routes/projects.d.ts.map +1 -1
  165. package/dist/server/routes/projects.js +101 -5
  166. package/dist/server/routes/projects.js.map +1 -1
  167. package/dist/server/routes/providers.d.ts +1 -1
  168. package/dist/server/routes/providers.d.ts.map +1 -1
  169. package/dist/server/routes/providers.js +3 -1
  170. package/dist/server/routes/providers.js.map +1 -1
  171. package/dist/server/routes/providers.test.d.ts +2 -0
  172. package/dist/server/routes/providers.test.d.ts.map +1 -0
  173. package/dist/server/routes/providers.test.js +58 -0
  174. package/dist/server/routes/providers.test.js.map +1 -0
  175. package/dist/server/routes/queue.d.ts +2 -1
  176. package/dist/server/routes/queue.d.ts.map +1 -1
  177. package/dist/server/routes/queue.js +89 -1
  178. package/dist/server/routes/queue.js.map +1 -1
  179. package/dist/server/routes/sessions.d.ts +8 -1
  180. package/dist/server/routes/sessions.d.ts.map +1 -1
  181. package/dist/server/routes/sessions.js +202 -9
  182. package/dist/server/routes/sessions.js.map +1 -1
  183. package/dist/server/serialize.d.ts +2 -0
  184. package/dist/server/serialize.d.ts.map +1 -1
  185. package/dist/server/serialize.js +2 -0
  186. package/dist/server/serialize.js.map +1 -1
  187. package/package.json +6 -2
@@ -0,0 +1,674 @@
1
+ import { describe, it, expect, vi, afterEach } from 'vitest';
2
+ import { mkdtemp, rm } from 'node:fs/promises';
3
+ import { join } from 'node:path';
4
+ import { tmpdir } from 'node:os';
5
+ import { WebSocket } from 'ws';
6
+ import { createStandaloneApp, injectRemoteProvider } from './app.js';
7
+ import { createLocalAuth } from '../auth/local.js';
8
+ import { JsonStore } from '../storage/json-store.js';
9
+ import { clearRemoteTokenProviders } from '../providers/remote.js';
10
+ const REMOTE_PROVIDER = {
11
+ name: 'itphx',
12
+ type: 'remote',
13
+ baseUrl: 'https://backend.test',
14
+ apiKey: '',
15
+ enabled: true,
16
+ };
17
+ const BACKEND_URL = 'https://backend.test';
18
+ const SERVER_USER = { id: 'u-1', username: 'lucky', roles: ['USER'] };
19
+ function jsonResponse(body, status = 200, headers = {}) {
20
+ return new Response(JSON.stringify(body), {
21
+ status,
22
+ headers: { 'Content-Type': 'application/json', ...headers },
23
+ });
24
+ }
25
+ /** Что реальный backend отдаёт в GET /api/v2/providers (включённый itphx). */
26
+ function defaultProvidersBody() {
27
+ return {
28
+ providers: [
29
+ {
30
+ name: 'itphx', type: 'openai', url: 'https://ai.itphx.ru/v1',
31
+ model: 'm1', enabled: true, is_default: true,
32
+ },
33
+ ],
34
+ default_provider: 'itphx',
35
+ };
36
+ }
37
+ /**
38
+ * fetch-роутер runtime-подключения: /auth/status (пользователь), /api/v2/providers
39
+ * (зеркалируемые провайдеры) и /api/v2/models (модели сервера) нужны самому
40
+ * connectRemoteWithApiKey+applyConnected; остальные URL уходят в onOther.
41
+ */
42
+ function hybridFetchMock(opts) {
43
+ const { modelsBody, providersBody, onOther } = opts;
44
+ return vi.fn((url, init) => {
45
+ const u = String(url);
46
+ if (u.includes('/auth/status')) {
47
+ return Promise.resolve(jsonResponse({ authenticated: true, oidc_enabled: true, user: SERVER_USER }));
48
+ }
49
+ if (u.includes('/api/v2/providers')) {
50
+ return Promise.resolve(jsonResponse(providersBody ?? defaultProvidersBody()));
51
+ }
52
+ if (u.includes('/api/v2/models')) {
53
+ return Promise.resolve(jsonResponse(modelsBody ?? { groups: [] }));
54
+ }
55
+ return Promise.resolve(onOther ? onOther(u, init) : jsonResponse({ error: 'not_found' }, 404));
56
+ });
57
+ }
58
+ function modelsBodyWith(remoteModels) {
59
+ return {
60
+ groups: [
61
+ { provider: { name: 'itphx', type: 'openai' }, models: remoteModels },
62
+ ],
63
+ };
64
+ }
65
+ afterEach(() => {
66
+ clearRemoteTokenProviders();
67
+ vi.unstubAllGlobals();
68
+ vi.restoreAllMocks();
69
+ });
70
+ describe('injectRemoteProvider (unit)', () => {
71
+ it('добавляет провайдера и модели в кэши, возвращает имя remote-провайдера', () => {
72
+ const providerCache = new Map();
73
+ const modelCache = new Map();
74
+ const name = injectRemoteProvider(providerCache, modelCache, {
75
+ provider: REMOTE_PROVIDER,
76
+ models: [
77
+ { id: 'remote/m1', name: 'm1', provider: 'itphx', enabled: true },
78
+ { id: 'remote/m2', name: 'm2', provider: 'itphx', enabled: true },
79
+ ],
80
+ getToken: async () => 'tok',
81
+ });
82
+ expect(name).toBe('itphx');
83
+ expect(providerCache.get('itphx')).toMatchObject({
84
+ type: 'remote',
85
+ baseUrl: 'https://backend.test',
86
+ enabled: true,
87
+ });
88
+ expect(modelCache.get('remote/m1')).toBeDefined();
89
+ expect(modelCache.get('remote/m2')).toBeDefined();
90
+ });
91
+ it('без remoteProvider — ничего не меняет', () => {
92
+ const providerCache = new Map();
93
+ const modelCache = new Map();
94
+ expect(injectRemoteProvider(providerCache, modelCache, undefined)).toBeUndefined();
95
+ expect(providerCache.size).toBe(0);
96
+ expect(modelCache.size).toBe(0);
97
+ });
98
+ });
99
+ describe('createStandaloneApp: remote-модели через runtime-подключение', () => {
100
+ it('после connectRemoteWithApiKey GET /api/v2/models форвардится на backend (прокси); в JsonStore ничего не пишется', async () => {
101
+ const dir = await mkdtemp(join(tmpdir(), 'synth-app-remote-'));
102
+ try {
103
+ const fetchMock = hybridFetchMock({
104
+ modelsBody: modelsBodyWith([
105
+ { id: 'remote/m1', name: 'm1', provider: 'itphx', description: '', enabled: true },
106
+ ]),
107
+ });
108
+ vi.stubGlobal('fetch', fetchMock);
109
+ const handle = await createStandaloneApp({
110
+ port: 0,
111
+ dataDir: dir,
112
+ apiKey: 'test-key',
113
+ defaultBackendUrl: BACKEND_URL,
114
+ remoteLlm: 'server',
115
+ });
116
+ await handle.connectRemoteWithApiKey(BACKEND_URL, 'tok');
117
+ const res = await handle.app.request('/api/v2/models', { headers: { Authorization: 'Bearer test-key' } });
118
+ expect(res.status).toBe(200);
119
+ const body = (await res.json());
120
+ const group = body.groups.find((g) => g.provider.name === 'itphx');
121
+ expect(group).toBeDefined();
122
+ expect(group.models.map((m) => m.id)).toEqual(['remote/m1']);
123
+ // Инъекция — только in-memory: в store ничего не записано.
124
+ const modelStore = new JsonStore({ dataDir: dir, subdir: 'models' });
125
+ expect(await modelStore.exists('remote/m1')).toBe(false);
126
+ const providerStore = new JsonStore({ dataDir: dir, subdir: 'providers' });
127
+ expect(await providerStore.exists('itphx')).toBe(false);
128
+ }
129
+ finally {
130
+ await rm(dir, { recursive: true, force: true }).catch(() => { });
131
+ }
132
+ });
133
+ it('без подключения — поведение не изменилось (remote-группы нет)', async () => {
134
+ const dir = await mkdtemp(join(tmpdir(), 'synth-app-remote-none-'));
135
+ try {
136
+ const { app } = await createStandaloneApp({ port: 0, dataDir: dir, apiKey: 'test-key' });
137
+ const res = await app.request('/api/v2/models', { headers: { Authorization: 'Bearer test-key' } });
138
+ expect(res.status).toBe(200);
139
+ const body = (await res.json());
140
+ expect(body.groups.find((g) => g.provider.name === 'itphx')).toBeUndefined();
141
+ }
142
+ finally {
143
+ await rm(dir, { recursive: true, force: true }).catch(() => { });
144
+ }
145
+ });
146
+ it('disconnectRemote убирает remote-провайдера/модели и сбрасывает гибрид-статус', async () => {
147
+ const dir = await mkdtemp(join(tmpdir(), 'synth-app-remote-disc-'));
148
+ try {
149
+ const fetchMock = hybridFetchMock({
150
+ modelsBody: modelsBodyWith([
151
+ { id: 'remote/m1', name: 'm1', provider: 'itphx', description: '', enabled: true },
152
+ ]),
153
+ });
154
+ vi.stubGlobal('fetch', fetchMock);
155
+ const handle = await createStandaloneApp({
156
+ port: 0,
157
+ dataDir: dir,
158
+ apiKey: 'test-key',
159
+ defaultBackendUrl: BACKEND_URL,
160
+ remoteLlm: 'server',
161
+ });
162
+ await handle.connectRemoteWithApiKey(BACKEND_URL, 'tok');
163
+ expect(handle.getHybridStatus().connected).toBe(true);
164
+ await handle.disconnectRemote();
165
+ expect(handle.getHybridStatus().connected).toBe(false);
166
+ expect(handle.getHybridStatus().backendUrl).toBeNull();
167
+ const res = await handle.app.request('/api/v2/models', { headers: { Authorization: 'Bearer test-key' } });
168
+ const body = (await res.json());
169
+ expect(body.groups.find((g) => g.provider.name === 'itphx')).toBeUndefined();
170
+ }
171
+ finally {
172
+ await rm(dir, { recursive: true, force: true }).catch(() => { });
173
+ }
174
+ });
175
+ it('disconnectRemote восстанавливает локального провайдера, перекрытого зеркалом', async () => {
176
+ const dir = await mkdtemp(join(tmpdir(), 'synth-app-remote-restore-'));
177
+ try {
178
+ // Локальный провайдер itphx (openai) + модель — конфликтует с серверным зеркалом.
179
+ const providerStore = new JsonStore({ dataDir: dir, subdir: 'providers' });
180
+ await providerStore.save('itphx', {
181
+ name: 'itphx', type: 'openai', baseUrl: 'http://local.test', apiKey: 'k', enabled: true,
182
+ });
183
+ const fetchMock = hybridFetchMock({
184
+ modelsBody: modelsBodyWith([
185
+ { id: 'itphx/m1', name: 'm1', provider: 'itphx', description: '', enabled: true },
186
+ ]),
187
+ });
188
+ vi.stubGlobal('fetch', fetchMock);
189
+ const handle = await createStandaloneApp({
190
+ port: 0,
191
+ dataDir: dir,
192
+ apiKey: 'test-key',
193
+ defaultBackendUrl: BACKEND_URL,
194
+ remoteLlm: 'server',
195
+ });
196
+ // До подключения в providerCache локальный itphx.
197
+ await handle.connectRemoteWithApiKey(BACKEND_URL, 'tok');
198
+ await handle.disconnectRemote();
199
+ // Локальная сессия с itphx после отключения резолвится (зеркало откачено):
200
+ // GET /api/v2/models не проксируется, локальная группа itphx снова видна.
201
+ const res = await handle.app.request('/api/v2/models', { headers: { Authorization: 'Bearer test-key' } });
202
+ const body = (await res.json());
203
+ const localGroup = body.groups.find((g) => g.provider.name === 'itphx');
204
+ expect(localGroup).toBeDefined();
205
+ expect(localGroup.provider.type).toBe('openai');
206
+ }
207
+ finally {
208
+ await rm(dir, { recursive: true, force: true }).catch(() => { });
209
+ }
210
+ });
211
+ });
212
+ describe('createStandaloneApp: зеркалирование серверных провайдеров (локальная сессия → LLM Proxy)', () => {
213
+ function sseResponse(events) {
214
+ return new Response(events.map((e) => `data: ${e}\n\n`).join(''), {
215
+ status: 200,
216
+ headers: { 'Content-Type': 'text/event-stream' },
217
+ });
218
+ }
219
+ it('локальная сессия резолвит зеркало itphx и шлёт запрос с provider в теле', { timeout: 20_000 }, async () => {
220
+ const dir = await mkdtemp(join(tmpdir(), 'synth-app-mirror-e2e-'));
221
+ try {
222
+ const llmProxyCalls = [];
223
+ const fetchMock = hybridFetchMock({
224
+ modelsBody: modelsBodyWith([
225
+ {
226
+ id: 'itphx/m1', name: 'm1', provider: 'itphx', description: '', enabled: true,
227
+ contextWindow: 16_384, inputPrice: 1, outputPrice: 2,
228
+ },
229
+ ]),
230
+ onOther: (url, init) => {
231
+ if (url.includes('/api/v2/llm/chat/completions')) {
232
+ const rawBody = typeof init?.body === 'string' ? init.body : '';
233
+ llmProxyCalls.push({ url, body: JSON.parse(rawBody) });
234
+ return sseResponse([
235
+ JSON.stringify({ choices: [{ index: 0, delta: { content: 'Ответ зеркала itphx' }, finish_reason: null }] }),
236
+ JSON.stringify({ choices: [{ index: 0, delta: {}, finish_reason: 'stop' }], usage: { prompt_tokens: 5, completion_tokens: 3, total_tokens: 8 } }),
237
+ '[DONE]',
238
+ ]);
239
+ }
240
+ return jsonResponse({}, 200);
241
+ },
242
+ });
243
+ vi.stubGlobal('fetch', fetchMock);
244
+ const handle = await createStandaloneApp({
245
+ port: 0,
246
+ dataDir: dir,
247
+ apiKey: 'test-key',
248
+ defaultBackendUrl: BACKEND_URL,
249
+ remoteLlm: 'server',
250
+ });
251
+ await handle.connectRemoteWithApiKey(BACKEND_URL, 'tok');
252
+ // Локальная сессия с провайдером/моделью сервера (имя зеркала = itphx).
253
+ const createRes = await handle.app.request('/api/v2/sessions', {
254
+ method: 'POST',
255
+ headers: { 'Content-Type': 'application/json', Authorization: 'Bearer test-key' },
256
+ body: JSON.stringify({
257
+ name: 'зеркало itphx',
258
+ settings: { provider: 'itphx', model: 'itphx/m1' },
259
+ }),
260
+ });
261
+ expect(createRes.status).toBe(201);
262
+ const ses = (await createRes.json());
263
+ expect(ses.id).toBeDefined();
264
+ const promptRes = await handle.app.request(`/api/v2/sessions/${ses.id}/prompt`, {
265
+ method: 'POST',
266
+ headers: { 'Content-Type': 'application/json', Authorization: 'Bearer test-key' },
267
+ body: JSON.stringify({ content: 'привет зеркалу' }),
268
+ });
269
+ expect(promptRes.status).toBe(200);
270
+ const text = await promptRes.text();
271
+ const events = text.split('\n')
272
+ .filter((l) => l.startsWith('data: '))
273
+ .map((l) => JSON.parse(l.slice(6)));
274
+ const done = events.find((e) => e.type === 'stream.done');
275
+ expect(done).toBeDefined();
276
+ expect(String(done.data.content)).toContain('Ответ зеркала itphx');
277
+ // RemoteProvider ушёл на сервер с ЯВНЫМ provider (имя зеркала) и моделью сервера.
278
+ const proxyCall = llmProxyCalls.find((c) => c.url.includes('/api/v2/llm/chat/completions'));
279
+ expect(proxyCall).toBeDefined();
280
+ expect(proxyCall.body.provider).toBe('itphx');
281
+ expect(proxyCall.body.model).toBe('itphx/m1');
282
+ }
283
+ finally {
284
+ await rm(dir, { recursive: true, force: true }).catch(() => { });
285
+ }
286
+ });
287
+ it('remote.llm=local: зеркало не перекрывает локального провайдера с тем же именем', { timeout: 20_000 }, async () => {
288
+ const dir = await mkdtemp(join(tmpdir(), 'synth-app-mirror-local-'));
289
+ try {
290
+ // Локальный провайдер itphx (openai) — при remote.llm=local он не перекрывается.
291
+ const providerStore = new JsonStore({ dataDir: dir, subdir: 'providers' });
292
+ await providerStore.save('itphx', {
293
+ name: 'itphx', type: 'openai', baseUrl: 'http://local.test', apiKey: 'k', enabled: true,
294
+ });
295
+ const fetchMock = hybridFetchMock({
296
+ modelsBody: modelsBodyWith([
297
+ { id: 'itphx/m1', name: 'm1', provider: 'itphx', description: '', enabled: true },
298
+ ]),
299
+ });
300
+ vi.stubGlobal('fetch', fetchMock);
301
+ const handle = await createStandaloneApp({
302
+ port: 0,
303
+ dataDir: dir,
304
+ apiKey: 'test-key',
305
+ defaultBackendUrl: BACKEND_URL,
306
+ remoteLlm: 'local',
307
+ });
308
+ await handle.connectRemoteWithApiKey(BACKEND_URL, 'tok');
309
+ // Локальная сессия всё ещё резолвит ЛОКАЛЬНЫЙ itphx (openai) — зеркало не создано.
310
+ const createRes = await handle.app.request('/api/v2/sessions', {
311
+ method: 'POST',
312
+ headers: { 'Content-Type': 'application/json', Authorization: 'Bearer test-key' },
313
+ body: JSON.stringify({
314
+ name: 'локальный itphx',
315
+ settings: { provider: 'itphx', model: 'itphx/m1' },
316
+ }),
317
+ });
318
+ expect(createRes.status).toBe(201);
319
+ // В remote.llm=local моделей сервера для itphx нет (провайдер перекрыт
320
+ // локальным) — зеркало не появилось. После отключения локальная группа
321
+ // itphx (openai) видна как раньше.
322
+ await handle.disconnectRemote();
323
+ const res = await handle.app.request('/api/v2/models', { headers: { Authorization: 'Bearer test-key' } });
324
+ const body = (await res.json());
325
+ const group = body.groups.find((g) => g.provider.name === 'itphx');
326
+ expect(group).toBeDefined();
327
+ expect(group.provider.type).toBe('openai');
328
+ }
329
+ finally {
330
+ await rm(dir, { recursive: true, force: true }).catch(() => { });
331
+ }
332
+ });
333
+ });
334
+ describe('createStandaloneApp: RemoteEventBridge и /api/v2/events (runtime)', () => {
335
+ it('после подключения /api/v2/events доставляет remote:<id> через subscribeAll', async () => {
336
+ const dir = await mkdtemp(join(tmpdir(), 'synth-app-bridge-'));
337
+ const abort = new AbortController();
338
+ try {
339
+ const fetchMock = hybridFetchMock({
340
+ modelsBody: modelsBodyWith([]),
341
+ // SSE-эндпоинт backend: стрим с одним событием и закрытием.
342
+ onOther: () => jsonResponse({}, 200),
343
+ });
344
+ vi.stubGlobal('fetch', fetchMock);
345
+ const handle = await createStandaloneApp({
346
+ port: 0,
347
+ dataDir: dir,
348
+ apiKey: 'test-key',
349
+ defaultBackendUrl: BACKEND_URL,
350
+ });
351
+ await handle.connectRemoteWithApiKey(BACKEND_URL, 'tok');
352
+ // Мост создан и запущен при подключении.
353
+ expect(handle.remoteEventBridge).toBeDefined();
354
+ const res = await handle.app.request('/api/v2/events', {
355
+ headers: { Authorization: 'Bearer test-key' },
356
+ signal: abort.signal,
357
+ });
358
+ expect(res.status).toBe(200);
359
+ const reader = res.body.getReader();
360
+ const decoder = new TextDecoder();
361
+ // То, что сделал бы мост: публикация события удалённой сессии (remote:<id>)
362
+ // и локальной сессии — обе доходят до UI через subscribeAll.
363
+ handle.eventBus.publish('remote:ses-1', 'session.updated', { id: 'remote:ses-1', name: 'x' });
364
+ handle.eventBus.publish('local-1', 'stream.chunk', { stream_id: 's1' });
365
+ const first = await reader.read();
366
+ expect(decoder.decode(first.value)).toContain('"sessionId":"remote:ses-1"');
367
+ const second = await reader.read();
368
+ expect(decoder.decode(second.value)).toContain('"sessionId":"local-1"');
369
+ }
370
+ finally {
371
+ abort.abort();
372
+ await rm(dir, { recursive: true, force: true }).catch(() => { });
373
+ }
374
+ });
375
+ });
376
+ describe('createStandaloneApp: WS /ws/events send_message с remote-рефом (runtime)', () => {
377
+ /**
378
+ * Открыть WS и начать собирать сообщения ДО установки соединения: сервер
379
+ * шлёт 'connected' сразу после upgrade, поэтому listener вешаем заранее.
380
+ */
381
+ function connectCollecting(port, apiKey) {
382
+ const received = [];
383
+ const ws = new WebSocket(`ws://127.0.0.1:${port}/ws/events`, {
384
+ headers: { Authorization: `Bearer ${apiKey}` },
385
+ });
386
+ ws.on('message', (raw) => {
387
+ try {
388
+ received.push(JSON.parse(raw.toString()));
389
+ }
390
+ catch { /* ignore */ }
391
+ });
392
+ const opened = new Promise((resolve, reject) => {
393
+ ws.on('open', () => resolve(ws));
394
+ ws.on('error', reject);
395
+ });
396
+ return { ws: opened, received };
397
+ }
398
+ async function startServer(config = {}) {
399
+ const dir = await mkdtemp(join(tmpdir(), 'synth-app-ws-remote-'));
400
+ const handle = await createStandaloneApp({
401
+ port: 0,
402
+ dataDir: dir,
403
+ apiKey: 'test-key',
404
+ ...config,
405
+ });
406
+ await new Promise((resolve) => handle.server.listen(0, resolve));
407
+ const address = handle.server.address();
408
+ const port = typeof address === 'object' && address ? address.port : 0;
409
+ return {
410
+ dir,
411
+ handle,
412
+ server: handle.server,
413
+ port,
414
+ close: async () => {
415
+ await handle.disconnectRemote?.().catch(() => { });
416
+ await new Promise((resolve) => handle.server.close(() => resolve()));
417
+ await rm(dir, { recursive: true, force: true }).catch(() => { });
418
+ },
419
+ };
420
+ }
421
+ it('remote:<id>: промпт уходит на backend (fetch), локальный оркестратор не трогается', async () => {
422
+ const fetchMock = hybridFetchMock({
423
+ modelsBody: modelsBodyWith([]),
424
+ onOther: (url, init) => {
425
+ if (url.includes('/prompt')) {
426
+ expect(init?.method).toBe('POST');
427
+ return jsonResponse({ ok: true });
428
+ }
429
+ return jsonResponse({}, 200);
430
+ },
431
+ });
432
+ vi.stubGlobal('fetch', fetchMock);
433
+ const app = await startServer({ defaultBackendUrl: BACKEND_URL });
434
+ await app.handle.connectRemoteWithApiKey(BACKEND_URL, 'tok');
435
+ const { ws: wsP, received } = connectCollecting(app.port, 'test-key');
436
+ const ws = await wsP;
437
+ try {
438
+ await vi.waitFor(() => {
439
+ expect(received.some((m) => m.type === 'connected')).toBe(true);
440
+ }, { timeout: 5000 });
441
+ ws.send(JSON.stringify({
442
+ type: 'send_message',
443
+ session_id: 'remote:ses-1',
444
+ content: 'привет удалённой сессии',
445
+ agent_role: 'developer',
446
+ model: 'remote/m1',
447
+ }));
448
+ await vi.waitFor(() => {
449
+ const promptCall = fetchMock.mock.calls.find(([u]) => String(u).includes('/api/v2/sessions/ses-1/prompt'));
450
+ expect(promptCall).toBeDefined();
451
+ }, { timeout: 5000 });
452
+ // Промпт ушёл на backend с локальным id (без префикса) и текстом.
453
+ const promptCall = fetchMock.mock.calls.find(([u]) => String(u).includes('/api/v2/sessions/ses-1/prompt'));
454
+ const [url, init] = promptCall;
455
+ expect(String(url)).toBe(`${BACKEND_URL}/api/v2/sessions/ses-1/prompt`);
456
+ const rawBody = init?.body;
457
+ const sentBody = typeof rawBody === 'string'
458
+ ? JSON.parse(rawBody)
459
+ : JSON.parse(new TextDecoder().decode(rawBody));
460
+ expect(sentBody.content).toBe('привет удалённой сессии');
461
+ expect(sentBody.agent).toBe('developer');
462
+ expect(sentBody.model).toBe('remote/m1');
463
+ // UI сначала получил queued, ошибки «Session not found» нет.
464
+ await vi.waitFor(() => {
465
+ expect(received.some((m) => m.type === 'queued' && m.session_id === 'remote:ses-1')).toBe(true);
466
+ }, { timeout: 5000 });
467
+ expect(received.some((m) => m.type === 'error')).toBe(false);
468
+ }
469
+ finally {
470
+ ws.close();
471
+ await app.close();
472
+ }
473
+ });
474
+ it('remote:<id> без подключения → error «Удалённый сервер не подключён»', async () => {
475
+ const app = await startServer({});
476
+ const { ws: wsP, received } = connectCollecting(app.port, 'test-key');
477
+ const ws = await wsP;
478
+ try {
479
+ // Ждём handshake-сообщение connected: сервер зарегистрировал on('message').
480
+ await vi.waitFor(() => {
481
+ expect(received.some((m) => m.type === 'connected')).toBe(true);
482
+ }, { timeout: 5000 });
483
+ ws.send(JSON.stringify({
484
+ type: 'send_message',
485
+ session_id: 'remote:ses-1',
486
+ content: 'привет',
487
+ }));
488
+ await vi.waitFor(() => {
489
+ expect(received.some((m) => m.type === 'error' && m.data?.error === 'Удалённый сервер не подключён')).toBe(true);
490
+ }, { timeout: 5000 });
491
+ }
492
+ finally {
493
+ ws.close();
494
+ await app.close();
495
+ }
496
+ });
497
+ it('ошибка backend на prompt → WS error в формате локальных ошибок', async () => {
498
+ const fetchMock = hybridFetchMock({
499
+ modelsBody: modelsBodyWith([]),
500
+ onOther: (url) => {
501
+ if (url.includes('/prompt')) {
502
+ return jsonResponse({ error: { code: 'RATE_LIMITED', message: 'HTTP 429 (remote)' } }, 429);
503
+ }
504
+ return jsonResponse({}, 200);
505
+ },
506
+ });
507
+ vi.stubGlobal('fetch', fetchMock);
508
+ const app = await startServer({ defaultBackendUrl: BACKEND_URL });
509
+ await app.handle.connectRemoteWithApiKey(BACKEND_URL, 'tok');
510
+ const { ws: wsP, received } = connectCollecting(app.port, 'test-key');
511
+ const ws = await wsP;
512
+ try {
513
+ await vi.waitFor(() => {
514
+ expect(received.some((m) => m.type === 'connected')).toBe(true);
515
+ }, { timeout: 5000 });
516
+ ws.send(JSON.stringify({
517
+ type: 'send_message',
518
+ session_id: 'remote:ses-1',
519
+ content: 'привет',
520
+ }));
521
+ await vi.waitFor(() => {
522
+ const hasHttp429 = received.some((m) => m.type === 'error'
523
+ && String(m.data?.error).includes('HTTP 429'));
524
+ expect(hasHttp429).toBe(true);
525
+ }, { timeout: 5000 });
526
+ }
527
+ finally {
528
+ ws.close();
529
+ await app.close();
530
+ }
531
+ });
532
+ });
533
+ describe('createStandaloneApp: WS /ws/events аутентификация через cookie v2 (server-identity)', () => {
534
+ function collect(ws) {
535
+ const received = [];
536
+ ws.on('message', (raw) => {
537
+ try {
538
+ received.push(JSON.parse(raw.toString()));
539
+ }
540
+ catch { /* ignore */ }
541
+ });
542
+ return received;
543
+ }
544
+ async function startServer() {
545
+ const dir = await mkdtemp(join(tmpdir(), 'synth-app-ws-cookie-'));
546
+ const handle = await createStandaloneApp({
547
+ port: 0,
548
+ dataDir: dir,
549
+ apiKey: 'test-key',
550
+ });
551
+ await new Promise((resolve) => handle.server.listen(0, resolve));
552
+ const address = handle.server.address();
553
+ const port = typeof address === 'object' && address ? address.port : 0;
554
+ return {
555
+ dir,
556
+ handle,
557
+ server: handle.server,
558
+ port,
559
+ close: async () => {
560
+ await handle.disconnectRemote?.().catch(() => { });
561
+ await new Promise((resolve) => handle.server.close(() => resolve()));
562
+ await rm(dir, { recursive: true, force: true }).catch(() => { });
563
+ },
564
+ };
565
+ }
566
+ /** Собрать подписанную server-identity cookie v2 через setServerUserCookie. */
567
+ function makeServerCookie(apiKey, user) {
568
+ let setCookie = '';
569
+ const auth = createLocalAuth({ apiKey });
570
+ auth.setServerUserCookie({
571
+ req: { header: () => undefined },
572
+ header: (name, value) => {
573
+ if (name === 'Set-Cookie')
574
+ setCookie = value;
575
+ },
576
+ }, user);
577
+ return setCookie.split(';')[0];
578
+ }
579
+ it('server-identity cookie v2 → соединение принимается (connected)', async () => {
580
+ const app = await startServer();
581
+ const cookie = makeServerCookie('test-key', { id: 'u-1', username: 'lucky', roles: ['USER'] });
582
+ const ws = await new Promise((resolve, reject) => {
583
+ const s = new WebSocket(`ws://127.0.0.1:${app.port}/ws/events`, {
584
+ headers: { Cookie: cookie },
585
+ });
586
+ s.on('open', () => resolve(s));
587
+ s.on('error', reject);
588
+ });
589
+ const received = collect(ws);
590
+ try {
591
+ await vi.waitFor(() => {
592
+ expect(received.some((m) => m.type === 'connected')).toBe(true);
593
+ }, { timeout: 5000 });
594
+ }
595
+ finally {
596
+ ws.close();
597
+ await app.close();
598
+ }
599
+ });
600
+ it('Bearer API-ключ по-прежнему работает (CLI/API)', async () => {
601
+ const app = await startServer();
602
+ const ws = await new Promise((resolve, reject) => {
603
+ const s = new WebSocket(`ws://127.0.0.1:${app.port}/ws/events`, {
604
+ headers: { Authorization: 'Bearer test-key' },
605
+ });
606
+ s.on('open', () => resolve(s));
607
+ s.on('error', reject);
608
+ });
609
+ const received = collect(ws);
610
+ try {
611
+ await vi.waitFor(() => {
612
+ expect(received.some((m) => m.type === 'connected')).toBe(true);
613
+ }, { timeout: 5000 });
614
+ }
615
+ finally {
616
+ ws.close();
617
+ await app.close();
618
+ }
619
+ });
620
+ it('невалидная cookie / без авторизации → 401', async () => {
621
+ const app = await startServer();
622
+ try {
623
+ const result = await new Promise((resolve) => {
624
+ const s = new WebSocket(`ws://127.0.0.1:${app.port}/ws/events`, {
625
+ headers: { Cookie: 'synth_session=invalid' },
626
+ });
627
+ s.on('unexpected-response', (_req, res) => resolve({ status: res.statusCode }));
628
+ s.on('open', () => resolve({}));
629
+ s.on('error', () => resolve({}));
630
+ });
631
+ expect(result.status).toBe(401);
632
+ }
633
+ finally {
634
+ await app.close();
635
+ }
636
+ });
637
+ });
638
+ describe('createStandaloneApp: HTTP-эндпоинты переноса проектов смонтированы (publish/fork-local)', () => {
639
+ it('без runtime-подключения publish → 502 REMOTE_UNAVAILABLE', async () => {
640
+ const dir = await mkdtemp(join(tmpdir(), 'synth-app-transfer-mount-'));
641
+ try {
642
+ const { app } = await createStandaloneApp({ port: 0, dataDir: dir, apiKey: 'test-key' });
643
+ const res = await app.request('/api/v2/projects/local-proj/publish', {
644
+ method: 'POST',
645
+ headers: { 'Content-Type': 'application/json', Authorization: 'Bearer test-key' },
646
+ body: JSON.stringify({}),
647
+ });
648
+ expect(res.status).toBe(502);
649
+ const body = (await res.json());
650
+ expect(body.error.code).toBe('REMOTE_UNAVAILABLE');
651
+ }
652
+ finally {
653
+ await rm(dir, { recursive: true, force: true }).catch(() => { });
654
+ }
655
+ });
656
+ it('без runtime-подключения fork-local → 502 REMOTE_UNAVAILABLE', async () => {
657
+ const dir = await mkdtemp(join(tmpdir(), 'synth-app-transfer-mount-'));
658
+ try {
659
+ const { app } = await createStandaloneApp({ port: 0, dataDir: dir, apiKey: 'test-key' });
660
+ const res = await app.request('/api/v2/projects/remote:abc/fork-local', {
661
+ method: 'POST',
662
+ headers: { 'Content-Type': 'application/json', Authorization: 'Bearer test-key' },
663
+ body: JSON.stringify({}),
664
+ });
665
+ expect(res.status).toBe(502);
666
+ const body = (await res.json());
667
+ expect(body.error.code).toBe('REMOTE_UNAVAILABLE');
668
+ }
669
+ finally {
670
+ await rm(dir, { recursive: true, force: true }).catch(() => { });
671
+ }
672
+ });
673
+ });
674
+ //# sourceMappingURL=app.remote.test.js.map